Files
photochk/Tests/PhotochkTests/FingerprintCacheTests.swift
yfoxjpandClaude Opus 5 c7f68b6cca Photochk 1.0.0 — 類似写真を探して整理する macOS アプリ
フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して
グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。

検出は安い判定から順に4段:
- ファイルサイズが同じものだけ SHA-256 でバイト一致を確定
- dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出
- 候補に出てくる画像だけ Vision の feature print で精査
- Union-Find で連結成分をグループ化

判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。
tools/release-update.sh で Gitea のリリースを更新する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:02:14 +09:00

82 lines
3.2 KiB
Swift

import Foundation
import Testing
import Vision
@testable import Photochk
@Suite("キャッシュ")
struct FingerprintCacheTests {
private func makeFile(at directory: URL) throws -> ScannedFile {
let url = directory.appendingPathComponent("cached.jpg")
try TestImages.writeJPEG(TestImages.pattern(seed: 3), to: url, quality: 0.9)
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
return ScannedFile(
url: url,
fileSize: Int64(values.fileSize ?? 0),
modificationDate: values.contentModificationDate ?? Date(),
creationDate: nil
)
}
@Test("dHash と feature print がディスクを経由して復元できる")
func roundTrip() async throws {
let directory = try TestImages.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = try makeFile(at: directory)
let cacheURL = directory.appendingPathComponent("cache.plist")
let basics = try #require(DHash.compute(for: file.url))
let print = try await FeaturePrinter.compute(for: file.url)
let writer = FingerprintCache(fileURL: cacheURL)
await writer.store(basics: [file.url: basics], for: [file])
await writer.store(featurePrints: [file.url: print], for: [file])
await writer.save()
let reader = FingerprintCache(fileURL: cacheURL)
let restoredBasics = await reader.basics(for: [file])
let restoredPrints = await reader.featurePrints(for: [file])
#expect(restoredBasics[file.url]?.dHash == basics.dHash)
let restoredPrint = try #require(restoredPrints[file.url])
// 同一の特徴量なので距離は 0
#expect(try restoredPrint.distance(to: print) == 0)
}
@Test("ファイルが更新されていたらキャッシュは使われない")
func invalidatesOnChange() async throws {
let directory = try TestImages.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = try makeFile(at: directory)
let cache = FingerprintCache(fileURL: nil)
let basics = try #require(DHash.compute(for: file.url))
await cache.store(basics: [file.url: basics], for: [file])
let changed = ScannedFile(
url: file.url,
fileSize: file.fileSize + 100,
modificationDate: file.modificationDate,
creationDate: nil
)
#expect(await cache.basics(for: [changed]).isEmpty)
#expect(await cache.basics(for: [file]).count == 1)
}
@Test("消えたファイルのエントリは prune で捨てられる")
func prunesMissingFiles() async throws {
let directory = try TestImages.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = try makeFile(at: directory)
let cache = FingerprintCache(fileURL: nil)
let basics = try #require(DHash.compute(for: file.url))
await cache.store(basics: [file.url: basics], for: [file])
await cache.prune(keeping: [])
#expect(await cache.basics(for: [file]).isEmpty)
}
}