Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions ImageDescriber.Test/ProgramTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImageDescriber.Tests;

using ktsu.ImageDescriber.Verbs;

[TestClass]
public class ProgramTests
{
private PersistentState? originalSettings;

[TestInitialize]
public void Initialize()
{
originalSettings = Program.Settings;
Program.Settings = new PersistentState();
}

[TestCleanup]
public void Cleanup() => Program.Settings = originalSettings!;

[TestMethod]
public void RunReturnsNonZeroWhenARequiredOptionIsMissing() =>
Assert.AreEqual(1, RunQuietly("Search"));

[TestMethod]
public void RunReturnsNonZeroWhenOllamaIsUnavailable()
{
string directory = Directory.CreateTempSubdirectory().FullName;
try
{
Assert.AreEqual(1, RunQuietly("Scan", "-p", directory, "-e", "http://127.0.0.1:1"));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}

[TestMethod]
public void RunReturnsNonZeroWhenTheImportFileIsMissing() =>
Assert.AreEqual(1, RunQuietly("Import", "-i", Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".csv")));

[TestMethod]
public void RunReturnsNonZeroWhenTheImportFileHasAnUnsupportedExtension()
{
string file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(file, string.Empty);
try
{
Assert.AreEqual(1, RunQuietly("Import", "-i", file));
}
finally
{
File.Delete(file);
}
}

[TestMethod]
public void RunReturnsNonZeroWhenTheExportFileHasAnUnsupportedExtension()
{
string hash = new('a', ImageHasher.HashLength);
Program.Settings.Descriptions[hash] = new ImageDescription { Hash = hash };

Assert.AreEqual(1, RunQuietly("Export", "-o", Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt")));
}

[TestMethod]
public void SearchReportsFailureForABlankQuery()
{
Search search = new();
RunQuietly(() => search.Run(new Search { Query = " " }));
Assert.IsTrue(search.Failed);
}

[TestMethod]
public void RunReturnsNonZeroWhenTheOllamaEndpointIsInvalid() =>
Assert.AreEqual(1, RunQuietly("Scan", "-p", Path.GetTempPath(), "-e", "ftp://example.com"));

[TestMethod]
public void RunReturnsNonZeroWhenThePromptedPathIsLeftEmpty() =>
Assert.AreEqual(1, RunQuietly("Import"));

[TestMethod]
public void RunReturnsZeroForVersion() =>
Assert.AreEqual(0, RunQuietly("--version"));

[TestMethod]
public void RunReturnsZeroWhenTheVerbSucceeds() =>
Assert.AreEqual(0, RunQuietly("Stats"));

[TestMethod]
public void RunReturnsZeroForHelp() =>
Assert.AreEqual(0, RunQuietly("--help"));

private static int RunQuietly(params string[] args)
{
int exitCode = 0;
RunQuietly(() => exitCode = Program.Run(args));
return exitCode;
}

/// <summary>
/// Runs <paramref name="action"/> with output discarded and an empty standard input, so a
/// verb that prompts for a missing value reads end-of-input instead of blocking.
/// </summary>
private static void RunQuietly(Action action)
{
TextReader originalIn = Console.In;
TextWriter originalOut = Console.Out;
TextWriter originalError = Console.Error;
try
{
Console.SetIn(new StringReader(string.Empty));
Console.SetOut(TextWriter.Null);
Console.SetError(TextWriter.Null);
action();
}
finally
{
Console.SetIn(originalIn);
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
}
20 changes: 18 additions & 2 deletions ImageDescriber/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,29 @@ internal static class Program
internal static Type[] Verbs { get; } = LoadVerbs();
internal static PersistentState Settings { get; set; } = new();

private static void Main(string[] args)
private static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
Settings = PersistentState.LoadOrCreate();

return Run(args);
}

/// <summary>
/// Runs the verb named by <paramref name="args"/> and returns the process exit code: 1 when the
/// arguments do not parse or the verb reports a failure, so scripts and CI can detect either.
/// </summary>
internal static int Run(string[] args)
{
int exitCode = 0;
_ = Parser.Default.ParseArguments(args, Verbs)
.WithParsed<BaseVerb>(task => task.Run());
.WithParsed<BaseVerb>(task =>
{
task.Run();
exitCode = task.Failed ? 1 : 0;
})
.WithNotParsed(errors => exitCode = errors.IsHelp() || errors.IsVersion() ? 0 : 1);
return exitCode;
}

private static Type[] LoadVerbs() => [.. Assembly.GetExecutingAssembly().GetTypes().Where(t => t.GetCustomAttribute<VerbAttribute>() != null)];
Expand Down
5 changes: 5 additions & 0 deletions ImageDescriber/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal abstract class BaseVerb : ICommand

public abstract bool IsActive { get; }

/// <summary>Whether the last run failed, which makes the process exit non-zero.</summary>
internal bool Failed { get; set; }

internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As<AbsoluteDirectoryPath>();

internal OllamaEndpoint Endpoint
Expand Down Expand Up @@ -49,8 +52,10 @@ internal abstract class BaseVerb<T> : BaseVerb where T : BaseVerb<T>

public override void Run()
{
Failed = false;
if (!ValidateArgs())
{
Failed = true;
return;
}

Expand Down
1 change: 1 addition & 0 deletions ImageDescriber/Verbs/Export.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ internal override void Run(Export options)
break;
default:
Console.WriteLine("Error: Output file must have .json or .csv extension.");
Failed = true;
return;
}

Expand Down
2 changes: 2 additions & 0 deletions ImageDescriber/Verbs/Import.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ internal override void Run(Import options)
if (!File.Exists(inputFile.WeakString))
{
Console.WriteLine($"File not found: {inputFile}");
Failed = true;
return;
}

List<ImageDescription>? entries = LoadEntries(inputFile);
if (entries is null)
{
Failed = true;
return;
}

Expand Down
3 changes: 3 additions & 0 deletions ImageDescriber/Verbs/Scan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ internal override void Run(Scan options)
if (OllamaClient.NormalizeEndpoint(options.Endpoint.WeakString) is null)
{
Console.WriteLine($"Error: Invalid Ollama endpoint \"{options.Endpoint}\". Expected an http or https address such as http://localhost:11434.");
Failed = true;
return;
}

Expand All @@ -56,6 +57,7 @@ internal override void Run(Scan options)
{
Console.WriteLine($"Error: Ollama is not available at {options.Endpoint}");
Console.WriteLine("Make sure Ollama is running and the endpoint is correct.");
Failed = true;
return;
}

Expand Down Expand Up @@ -150,6 +152,7 @@ internal override void Run(Scan options)
});

PrintFailureSummary(failures);
Failed = failures.Count > 0;

Console.WriteLine();
Console.WriteLine("Scan complete.");
Expand Down
1 change: 1 addition & 0 deletions ImageDescriber/Verbs/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ internal override void Run(Search options)
if (string.IsNullOrWhiteSpace(options.Query))
{
Console.WriteLine("Error: Search query cannot be empty.");
Failed = true;
return;
}

Expand Down
Loading