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>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Photochk
|
||||
|
||||
@Suite("dHash")
|
||||
struct DHashTests {
|
||||
@Test("同じ値のハミング距離は 0")
|
||||
func identical() {
|
||||
#expect(DHash.hammingDistance(0xDEAD_BEEF_1234_5678, 0xDEAD_BEEF_1234_5678) == 0)
|
||||
}
|
||||
|
||||
@Test("違うビットの数がそのまま距離になる")
|
||||
func differingBits() {
|
||||
#expect(DHash.hammingDistance(0, 0b1011) == 3)
|
||||
#expect(DHash.hammingDistance(.max, 0) == 64)
|
||||
#expect(DHash.hammingDistance(0b1100, 0b0110) == 2)
|
||||
}
|
||||
|
||||
@Test("生成した画像から 64bit のハッシュが取れる")
|
||||
func computesFromImage() throws {
|
||||
let directory = try TestImages.makeTemporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let url = directory.appendingPathComponent("base.jpg")
|
||||
try TestImages.writeJPEG(TestImages.pattern(seed: 1), to: url, quality: 0.9)
|
||||
|
||||
let basics = try #require(DHash.compute(for: url))
|
||||
#expect(basics.pixelWidth == TestImages.width)
|
||||
#expect(basics.pixelHeight == TestImages.height)
|
||||
|
||||
// 同じファイルからは必ず同じ値が出る
|
||||
let again = try #require(DHash.compute(for: url))
|
||||
#expect(basics.dHash == again.dHash)
|
||||
}
|
||||
|
||||
@Test("リサイズしてもハッシュはほとんど変わらず、別の絵柄とは大きく離れる")
|
||||
func robustToResize() throws {
|
||||
let directory = try TestImages.makeTemporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let base = directory.appendingPathComponent("base.jpg")
|
||||
let small = directory.appendingPathComponent("small.jpg")
|
||||
let other = directory.appendingPathComponent("other.jpg")
|
||||
|
||||
let pattern = TestImages.pattern(seed: 7)
|
||||
try TestImages.writeJPEG(pattern, to: base, quality: 0.9)
|
||||
try TestImages.writeJPEG(TestImages.scaled(pattern, factor: 0.5), to: small, quality: 0.8)
|
||||
try TestImages.writeJPEG(TestImages.pattern(seed: 99), to: other, quality: 0.9)
|
||||
|
||||
let baseHash = try #require(DHash.compute(for: base)).dHash
|
||||
let smallHash = try #require(DHash.compute(for: small)).dHash
|
||||
let otherHash = try #require(DHash.compute(for: other)).dHash
|
||||
|
||||
#expect(DHash.hammingDistance(baseHash, smallHash) <= 12)
|
||||
#expect(DHash.hammingDistance(baseHash, otherHash) > 12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Photochk
|
||||
|
||||
@Suite("選択ルール")
|
||||
@MainActor
|
||||
struct SelectionTests {
|
||||
private func asset(
|
||||
_ name: String, width: Int, height: Int, size: Int64, days: Double
|
||||
) -> PhotoAsset {
|
||||
PhotoAsset(
|
||||
url: URL(fileURLWithPath: "/tmp/photochk/\(name)"),
|
||||
fileSize: size,
|
||||
modificationDate: Date(timeIntervalSince1970: days * 86_400),
|
||||
captureDate: Date(timeIntervalSince1970: days * 86_400),
|
||||
pixelWidth: width,
|
||||
pixelHeight: height
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGroup() -> SimilarityGroup {
|
||||
let big = asset("big.jpg", width: 4000, height: 3000, size: 5_000_000, days: 10)
|
||||
let mid = asset("mid.jpg", width: 2000, height: 1500, size: 2_000_000, days: 20)
|
||||
let small = asset("small.jpg", width: 1000, height: 750, size: 500_000, days: 1)
|
||||
|
||||
return SimilarityGroup(
|
||||
members: [
|
||||
.init(asset: big, relation: .exact),
|
||||
.init(asset: mid, relation: .similar(distance: 0.1)),
|
||||
.init(asset: small, relation: .similar(distance: 0.2)),
|
||||
],
|
||||
representativeID: big.url
|
||||
)
|
||||
}
|
||||
|
||||
private func makeModel() -> ScanViewModel {
|
||||
let model = ScanViewModel()
|
||||
model.setGroupsForTesting([makeGroup()])
|
||||
return model
|
||||
}
|
||||
|
||||
@Test("最後の1枚は選択できない")
|
||||
func keepsAtLeastOne() {
|
||||
let model = makeModel()
|
||||
let group = model.groups[0]
|
||||
let ids = group.members.map(\.id)
|
||||
|
||||
model.toggleMark(ids[0], in: group)
|
||||
model.toggleMark(ids[1], in: group)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
// 3枚目を選ぶと全滅するので拒否される
|
||||
#expect(model.canMark(ids[2], in: group) == false)
|
||||
model.toggleMark(ids[2], in: group)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
// すでに選んでいるものは解除できる
|
||||
#expect(model.canMark(ids[0], in: group) == true)
|
||||
}
|
||||
|
||||
@Test("解像度が最も高い1枚を残す")
|
||||
func keepHighestResolution() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
|
||||
let kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.count == 1)
|
||||
#expect(kept[0].asset.fileName == "big.jpg")
|
||||
}
|
||||
|
||||
@Test("最も新しい1枚 / 最も古い1枚を残す")
|
||||
func keepNewestAndOldest() {
|
||||
let model = makeModel()
|
||||
|
||||
model.applyKeepRule(.newest)
|
||||
var kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.map(\.asset.fileName) == ["mid.jpg"])
|
||||
|
||||
model.applyKeepRule(.oldest)
|
||||
kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.map(\.asset.fileName) == ["small.jpg"])
|
||||
}
|
||||
|
||||
@Test("選択解除ですべての印が消える")
|
||||
func clearMarks() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
model.clearMarks()
|
||||
#expect(model.markedCount == 0)
|
||||
#expect(model.markedBytes == 0)
|
||||
}
|
||||
|
||||
@Test("選択した容量の合計が集計される")
|
||||
func markedBytes() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
#expect(model.markedBytes == 2_500_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@testable import Photochk
|
||||
|
||||
/// 画面が実際に描けることを確かめる。
|
||||
///
|
||||
/// 描いた PNG は一時フォルダに残すので、目視で確認したいときはテスト出力に出るパスを開く。
|
||||
@Suite("画面の描画")
|
||||
@MainActor
|
||||
struct SnapshotTests {
|
||||
/// 実物に近い見た目にするため、寸法・容量・撮影日が散らばったグループを作る。
|
||||
private func makeFixtureModel() async throws -> (ScanViewModel, URL) {
|
||||
let directory = try TestImages.makeTemporaryDirectory()
|
||||
let pattern = TestImages.pattern(seed: 42)
|
||||
|
||||
let base = directory.appendingPathComponent("IMG_0001.jpg")
|
||||
try TestImages.writeJPEG(pattern, to: base, quality: 0.95)
|
||||
try FileManager.default.copyItem(
|
||||
at: base, to: directory.appendingPathComponent("IMG_0001のコピー.jpg")
|
||||
)
|
||||
try TestImages.writeJPEG(
|
||||
TestImages.scaled(pattern, factor: 0.5),
|
||||
to: directory.appendingPathComponent("IMG_0001_small.jpg"),
|
||||
quality: 0.9
|
||||
)
|
||||
try TestImages.writeJPEG(
|
||||
pattern, to: directory.appendingPathComponent("IMG_0001_line.jpg"), quality: 0.3
|
||||
)
|
||||
let other = TestImages.pattern(seed: 9_999)
|
||||
try TestImages.writeJPEG(other, to: directory.appendingPathComponent("IMG_9000.jpg"), quality: 0.95)
|
||||
try FileManager.default.copyItem(
|
||||
at: directory.appendingPathComponent("IMG_9000.jpg"),
|
||||
to: directory.appendingPathComponent("IMG_9000_backup.jpg")
|
||||
)
|
||||
|
||||
let groups = try await SimilarityGrouper.run(
|
||||
root: directory,
|
||||
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
|
||||
cache: FingerprintCache(fileURL: nil),
|
||||
progress: { _ in }
|
||||
)
|
||||
|
||||
let model = ScanViewModel()
|
||||
model.setGroupsForTesting(groups)
|
||||
// サムネイルを先に読み込んでおく。描画時はキャッシュから同期的に出る。
|
||||
for group in groups {
|
||||
for member in group.members {
|
||||
_ = await ThumbnailLoader.shared.thumbnail(for: member.id, size: 168)
|
||||
_ = await ThumbnailLoader.shared.thumbnail(for: member.id, size: 52)
|
||||
}
|
||||
}
|
||||
return (model, directory)
|
||||
}
|
||||
|
||||
private func render(
|
||||
_ view: some View, width: CGFloat, height: CGFloat, name: String
|
||||
) throws -> URL {
|
||||
let renderer = ImageRenderer(
|
||||
content: view
|
||||
.frame(width: width, height: height)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
)
|
||||
renderer.scale = 2
|
||||
|
||||
let image = try #require(renderer.nsImage, "\(name) を描画できなかった")
|
||||
let tiff = try #require(image.tiffRepresentation)
|
||||
let bitmap = try #require(NSBitmapImageRep(data: tiff))
|
||||
let png = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("photochk-snapshots", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent("\(name).png")
|
||||
try png.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("写真タイルが状態ごとに描画できる")
|
||||
func rendersPhotoTiles() async throws {
|
||||
let (model, directory) = try await makeFixtureModel()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let group = try #require(model.groups.first)
|
||||
try #require(group.members.count >= 4, "タイルを並べるだけの枚数が必要")
|
||||
|
||||
// 代表 / 通常 / 選択済み / 最後の1枚(選択不可)を横に並べる
|
||||
let tiles = HStack(alignment: .top, spacing: 18) {
|
||||
PhotoTileView(
|
||||
member: group.members[0], isRepresentative: true,
|
||||
isMarked: false, canMark: true, onToggle: {}
|
||||
)
|
||||
PhotoTileView(
|
||||
member: group.members[1], isRepresentative: false,
|
||||
isMarked: false, canMark: true, onToggle: {}
|
||||
)
|
||||
PhotoTileView(
|
||||
member: group.members[2], isRepresentative: false,
|
||||
isMarked: true, canMark: true, onToggle: {}
|
||||
)
|
||||
PhotoTileView(
|
||||
member: group.members[3], isRepresentative: false,
|
||||
isMarked: false, canMark: false, onToggle: {}
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
let url = try render(tiles, width: 800, height: 300, name: "photo-tiles")
|
||||
print("[snapshot] \(url.path)")
|
||||
}
|
||||
|
||||
@Test("グループ一覧の行が描画できる")
|
||||
func rendersGroupRows() async throws {
|
||||
let (model, directory) = try await makeFixtureModel()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
model.applyKeepRule(.highestResolution)
|
||||
|
||||
let rows = VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(model.groups) { group in
|
||||
GroupRow(
|
||||
group: group,
|
||||
markedCount: group.members.filter {
|
||||
model.markedForDeletion.contains($0.id)
|
||||
}.count
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
let url = try render(rows, width: 300, height: 220, name: "group-rows")
|
||||
print("[snapshot] \(url.path)")
|
||||
}
|
||||
|
||||
@Test("スキャン中の表示が描画できる")
|
||||
func rendersProgress() throws {
|
||||
let url = try render(
|
||||
ScanProgressView(
|
||||
progress: ScanProgress(phase: .fingerprinting, completed: 128, total: 940),
|
||||
onCancel: {}
|
||||
),
|
||||
width: 420, height: 220, name: "progress"
|
||||
)
|
||||
print("[snapshot] \(url.path)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// テスト用の画像を決まった内容で作る。乱数は自前の線形合同法なので毎回同じ絵になる。
|
||||
enum TestImages {
|
||||
static let width = 640
|
||||
static let height = 480
|
||||
|
||||
struct Generator {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) { state = seed &* 2_862_933_555_777_941_757 &+ 3_037_000_493 }
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
|
||||
return state
|
||||
}
|
||||
|
||||
mutating func unit() -> Double {
|
||||
Double(next() >> 11) / Double(1 << 53)
|
||||
}
|
||||
}
|
||||
|
||||
/// 色のついたブロックと円を敷き詰めた、構造のある絵を作る。
|
||||
static func pattern(seed: UInt64) -> CGImage {
|
||||
var generator = Generator(seed: seed)
|
||||
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||
)!
|
||||
|
||||
let columns = 8
|
||||
let rows = 6
|
||||
let cellWidth = CGFloat(width) / CGFloat(columns)
|
||||
let cellHeight = CGFloat(height) / CGFloat(rows)
|
||||
|
||||
for row in 0..<rows {
|
||||
for column in 0..<columns {
|
||||
context.setFillColor(
|
||||
red: generator.unit(),
|
||||
green: generator.unit(),
|
||||
blue: generator.unit(),
|
||||
alpha: 1
|
||||
)
|
||||
context.fill(
|
||||
CGRect(
|
||||
x: CGFloat(column) * cellWidth,
|
||||
y: CGFloat(row) * cellHeight,
|
||||
width: cellWidth,
|
||||
height: cellHeight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..<12 {
|
||||
context.setFillColor(
|
||||
red: generator.unit(),
|
||||
green: generator.unit(),
|
||||
blue: generator.unit(),
|
||||
alpha: 0.85
|
||||
)
|
||||
let radius = 20 + generator.unit() * 60
|
||||
context.fillEllipse(
|
||||
in: CGRect(
|
||||
x: generator.unit() * Double(width),
|
||||
y: generator.unit() * Double(height),
|
||||
width: radius,
|
||||
height: radius
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return context.makeImage()!
|
||||
}
|
||||
|
||||
static func scaled(_ image: CGImage, factor: Double) -> CGImage {
|
||||
let newWidth = max(1, Int(Double(image.width) * factor))
|
||||
let newHeight = max(1, Int(Double(image.height) * factor))
|
||||
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||
)!
|
||||
context.interpolationQuality = .high
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
|
||||
return context.makeImage()!
|
||||
}
|
||||
|
||||
static func writeJPEG(_ image: CGImage, to url: URL, quality: Double) throws {
|
||||
guard let destination = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, UTType.jpeg.identifier as CFString, 1, nil
|
||||
) else {
|
||||
throw TestImageError.cannotWrite(url)
|
||||
}
|
||||
CGImageDestinationAddImage(
|
||||
destination,
|
||||
image,
|
||||
[kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
|
||||
)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw TestImageError.cannotWrite(url)
|
||||
}
|
||||
}
|
||||
|
||||
static func makeTemporaryDirectory() throws -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("PhotochkTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
enum TestImageError: Error {
|
||||
case cannotWrite(URL)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Testing
|
||||
|
||||
@testable import Photochk
|
||||
|
||||
@Suite("UnionFind")
|
||||
struct UnionFindTests {
|
||||
@Test("結合していない要素はそれぞれ単独の成分になる")
|
||||
func isolatedElements() {
|
||||
var uf = UnionFind(count: 4)
|
||||
let components = uf.components().map { $0.sorted() }.sorted { $0[0] < $1[0] }
|
||||
#expect(components == [[0], [1], [2], [3]])
|
||||
}
|
||||
|
||||
@Test("推移的に結合した要素は1つの成分にまとまる")
|
||||
func transitiveMerge() {
|
||||
var uf = UnionFind(count: 5)
|
||||
uf.union(0, 1)
|
||||
uf.union(1, 2)
|
||||
uf.union(3, 4)
|
||||
|
||||
let components = uf.components().map { $0.sorted() }.sorted { $0[0] < $1[0] }
|
||||
#expect(components == [[0, 1, 2], [3, 4]])
|
||||
}
|
||||
|
||||
@Test("同じ成分をもう一度 union しても何も変わらない")
|
||||
func repeatedUnion() {
|
||||
var uf = UnionFind(count: 3)
|
||||
#expect(uf.union(0, 1) == true)
|
||||
#expect(uf.union(1, 0) == false)
|
||||
#expect(uf.find(0) == uf.find(1))
|
||||
#expect(uf.find(0) != uf.find(2))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user