From e051258f25f63daf103dab94c1a9f51769010084 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:23:58 +0000 Subject: [PATCH] Normalize imported hashes to lower case and default their null fields [patch] An imported entry with an upper-case hash was stored under a key Scan never looks up, and a JSON null in Description, KnownPaths, SuggestedFileName or Model made Search, Stats, Export and Scan throw on every later run. Fixes ktsu-dev/ImageDescriber#163 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015PVg1hwPKWXb1nLDuTQgYR --- ImageDescriber.Test/SearchTests.cs | 51 ++++++++++++++++++++++++++++++ ImageDescriber/Verbs/Import.cs | 17 ++++++++++ 2 files changed, 68 insertions(+) diff --git a/ImageDescriber.Test/SearchTests.cs b/ImageDescriber.Test/SearchTests.cs index 64b82b8..6de74e2 100644 --- a/ImageDescriber.Test/SearchTests.cs +++ b/ImageDescriber.Test/SearchTests.cs @@ -55,6 +55,57 @@ public void MergeEntriesSkipsEntriesWhoseHashIsNotSha256Hex() CollectionAssert.AreEquivalent(expectedKeys, Program.Settings.Descriptions.Keys.ToArray()); } + [TestMethod] + public void MergeEntriesStoresAnUpperCaseHashAsTheLowerCaseHashScanLooksUp() + { + Program.Settings = new PersistentState(); + string lowerHash = new('a', ImageHasher.HashLength); + string upperHash = lowerHash.ToUpperInvariant(); + AbsoluteFilePath firstPath = Path.Combine(Path.GetTempPath(), "first.jpg").As(); + AbsoluteFilePath secondPath = Path.Combine(Path.GetTempPath(), "second.jpg").As(); + + (int NewCount, int UpdatedCount, int SkippedCount) first = default; + (int NewCount, int UpdatedCount, int SkippedCount) second = default; + CaptureConsole(() => first = Import.MergeEntries([new() { Hash = upperHash, KnownPaths = [firstPath] }])); + CaptureConsole(() => second = Import.MergeEntries([new() { Hash = lowerHash, KnownPaths = [secondPath] }])); + + Assert.AreEqual(1, first.NewCount); + Assert.AreEqual(1, second.UpdatedCount); + string[] expectedKeys = [lowerHash]; + CollectionAssert.AreEquivalent(expectedKeys, Program.Settings.Descriptions.Keys.ToArray()); + Assert.AreEqual(lowerHash, Program.Settings.Descriptions[lowerHash].Hash); + Assert.AreEqual(2, Program.Settings.Descriptions[lowerHash].KnownPaths.Count); + } + + [TestMethod] + public void MergeEntriesRepairsNullFieldsSoSearchStatsAndExportStillRun() + { + Program.Settings = new PersistentState(); + string hash = new('b', ImageHasher.HashLength); + List entries = + [ + new() + { + Hash = hash, + Description = null!, + KnownPaths = null!, + SuggestedFileName = null!, + Model = null!, + }, + ]; + + CaptureConsole(() => Import.MergeEntries(entries)); + + ImageDescription stored = Program.Settings.Descriptions[hash]; + Assert.IsNotNull(stored.Description); + Assert.IsNotNull(stored.KnownPaths); + Assert.IsNotNull(stored.SuggestedFileName); + Assert.IsNotNull(stored.Model); + CaptureConsole(() => new Search().Run(new Search { Query = "dog" })); + CaptureConsole(() => new Stats().Run(new Stats())); + StringAssert.Contains(Export.BuildCsv(Program.Settings.Descriptions.Values), hash); + } + [TestMethod] public void IsValidHashAcceptsOnlySha256HexStrings() { diff --git a/ImageDescriber/Verbs/Import.cs b/ImageDescriber/Verbs/Import.cs index 91e3f62..5fdecec 100644 --- a/ImageDescriber/Verbs/Import.cs +++ b/ImageDescriber/Verbs/Import.cs @@ -108,6 +108,11 @@ internal static (int NewCount, int UpdatedCount, int SkippedCount) MergeEntries( continue; } + // Scan looks hashes up as ComputeHash spells them, lower-case, and the store is keyed + // case-sensitively, so an upper-case hash from another tool would never match. + entry.Hash = entry.Hash.ToLowerInvariant(); + RepairNullFields(entry); + if (Program.Settings.Descriptions.TryGetValue(entry.Hash, out ImageDescription? existing)) { if (MergeKnownPaths(entry, existing)) @@ -129,6 +134,18 @@ internal static (int NewCount, int UpdatedCount, int SkippedCount) MergeEntries( return (newCount, updatedCount, skippedCount); } + /// + /// Defaults the fields a JSON import can set to null. Search, Stats, Export and Scan all + /// dereference them, so a stored null would make each of them throw on every later run. + /// + internal static void RepairNullFields(ImageDescription entry) + { + entry.KnownPaths = entry.KnownPaths is null ? [] : [.. entry.KnownPaths.Where(p => p is not null)]; + entry.Description ??= string.Empty; + entry.SuggestedFileName ??= string.Empty.As(); + entry.Model ??= string.Empty.As(); + } + internal static bool MergeKnownPaths(ImageDescription source, ImageDescription target) { bool pathsAdded = false;