From 192fcca9c02892d7193f05ae6be1983e7f237c0c Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:45:50 +0000 Subject: [PATCH] Add end-to-end grouping tests --- Tests/PhotochkTests/GroupingTests.swift | 166 ++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 Tests/PhotochkTests/GroupingTests.swift diff --git a/Tests/PhotochkTests/GroupingTests.swift b/Tests/PhotochkTests/GroupingTests.swift new file mode 100644 index 0000000..66d93cb --- /dev/null +++ b/Tests/PhotochkTests/GroupingTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing + +@testable import Photochk + +/// 走査からグループ生成まで、実際に画像ファイルを作って一気通貫で確かめる。 +@Suite("グループ生成") +struct GroupingTests { + /// 同じ絵の派生(完全コピー・縮小・低画質)と、無関係な絵を1つ置いたフォルダを作る。 + private func makeFixture() throws -> URL { + let directory = try TestImages.makeTemporaryDirectory() + let subdirectory = directory.appendingPathComponent("sub", isDirectory: true) + try FileManager.default.createDirectory(at: subdirectory, withIntermediateDirectories: true) + + let pattern = TestImages.pattern(seed: 42) + let base = directory.appendingPathComponent("base.jpg") + try TestImages.writeJPEG(pattern, to: base, quality: 0.95) + + // バイト単位で同じコピー + try FileManager.default.copyItem( + at: base, to: directory.appendingPathComponent("base_copy.jpg") + ) + // 縮小版と、そのバイト単位のコピー(代表以外にできる完全同一の組) + let small = directory.appendingPathComponent("base_small.jpg") + try TestImages.writeJPEG( + TestImages.scaled(pattern, factor: 0.5), to: small, quality: 0.9 + ) + try FileManager.default.copyItem( + at: small, to: directory.appendingPathComponent("base_small_copy.jpg") + ) + // 低画質版 + try TestImages.writeJPEG( + pattern, to: directory.appendingPathComponent("base_low.jpg"), quality: 0.3 + ) + // サブフォルダにもう1つ派生を置く + try TestImages.writeJPEG( + TestImages.scaled(pattern, factor: 0.75), + to: subdirectory.appendingPathComponent("base_sub.jpg"), + quality: 0.85 + ) + // 無関係な絵 + try TestImages.writeJPEG( + TestImages.pattern(seed: 9_999), + to: directory.appendingPathComponent("other.jpg"), + quality: 0.95 + ) + + return directory + } + + @Test("派生画像が1グループにまとまり、無関係な画像は入らない") + func groupsDerivatives() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + + #expect(groups.count == 1) + let group = try #require(groups.first) + + let names = Set(group.members.map(\.asset.fileName)) + #expect( + names == [ + "base.jpg", "base_copy.jpg", "base_small.jpg", "base_small_copy.jpg", + "base_low.jpg", "base_sub.jpg", + ] + ) + #expect(!names.contains("other.jpg")) + } + + @Test("代表以外にできた完全同一の組には同じ印が付く") + func tagsExactSetsAwayFromRepresentative() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + let tags = Dictionary( + uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.exactSetTag) } + ) + + // 縮小版の2枚は互いにバイト単位で同じなので、同じ印が付く + let smallTag = try #require(tags["base_small.jpg"] ?? nil) + #expect(tags["base_small_copy.jpg"] == smallTag) + + // 代表と完全同一のものは relation が exact になるので印は要らない + #expect(tags["base_copy.jpg"] == .some(nil)) + #expect(tags["base_low.jpg"] == .some(nil)) + } + + @Test("完全同一のコピーは exact、加工された派生は similar になる") + func relationKinds() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + + // 代表は元画像か、それとバイト単位で同じコピー + #expect(["base.jpg", "base_copy.jpg"].contains(group.representative.fileName)) + #expect(group.representative.pixelWidth == TestImages.width) + + let relations = Dictionary( + uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.relation) } + ) + #expect(relations["base.jpg"]?.isExact == true) + #expect(relations["base_copy.jpg"]?.isExact == true) + #expect(relations["base_small.jpg"]?.isExact == false) + #expect(relations["base_low.jpg"]?.isExact == false) + + // 加工された派生には feature print 由来の距離が入っている + if case .similar(let distance) = relations["base_small.jpg"] { + let value = try #require(distance) + #expect(value < Sensitivity.standard.featurePrintThreshold) + } else { + Issue.record("base_small.jpg は similar であるべき") + } + } + + @Test("サブフォルダを含めないとサブフォルダの画像は対象外になる") + func respectsRecursionSetting() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: false, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + #expect(!group.members.contains { $0.asset.fileName == "base_sub.jpg" }) + #expect(group.members.count == 5) + } + + @Test("削減できるバイト数は代表1枚を残した残り") + func reclaimableBytes() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + #expect(group.reclaimableBytes == group.totalBytes - group.representative.fileSize) + #expect(group.reclaimableBytes > 0) + } +}