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) } }