Files
onyx/Packages/OnyxKit/Tests/ClipboardProviderTests/ClipboardRulesTests.swift
T
ScarriffleandClaude Opus 5 3da673662f Zwischenablage, Notizen und der Gesichtsvergleich
Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom
Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` —
was dort steht, liest jedes Programm mit `defaults read` mit.

Die Zwischenablage achtet `ConcealedType`, `TransientType` und
`AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und
kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein.

Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein
Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber
`VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und
ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig.

`FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine
Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches
Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort,
das man ohnehin kennt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00

144 lines
5.9 KiB
Swift

import Testing
import Foundation
@testable import ClipboardProvider
@Suite("Zwischenablage: die Regeln")
struct ClipboardRulesTests {
private func item(_ fingerprint: String, at seconds: TimeInterval = 0,
preview: String = "x") -> ClipboardItem {
ClipboardItem(kind: .text, preview: preview,
addedAt: Date(timeIntervalSince1970: seconds),
sourceBundleID: nil, fingerprint: fingerprint)
}
// MARK: - Was nie gemerkt werden darf
@Test("Ein als verborgen markierter Inhalt wird nicht gemerkt")
func concealedIsIgnored() {
// Passwortmanager setzen diese Marke. Sie zu missachten hieße, fremde
// Passwörter einzusammeln.
#expect(!ClipboardRules.shouldRecord(
types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Durchreichen und Selbsterzeugtes ebenso wenig")
func transientAndGeneratedAreIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.TransientType"],
sourceBundleID: nil, excludedApps: []))
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.AutoGeneratedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Gewöhnlicher Text schon")
func plainTextIsRecorded() {
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.apple.TextEdit",
excludedApps: []))
}
@Test("Aus einem ausgeschlossenen Programm nicht")
func excludedAppIsIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.agilebits.onepassword7",
excludedApps: ["com.agilebits.onepassword7"]))
}
@Test("Ohne bekanntes Herkunftsprogramm wird gemerkt")
func unknownSourceIsRecorded() {
// Sonst ginge alles verloren, was aus einem Hintergrunddienst kommt.
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: nil,
excludedApps: ["com.agilebits.onepassword7"]))
}
// MARK: - Vorschau
@Test("Zeilenumbrüche werden zu Leerzeichen")
func previewIsOneLine() {
#expect(ClipboardRules.preview(for: "erste\nzweite") == "erste zweite")
#expect(ClipboardRules.preview(for: "a\t\tb") == "a b")
}
@Test("Zu langer Text wird gekürzt und bekommt Auslassungspunkte")
func previewIsTruncated() {
let long = String(repeating: "a", count: 500)
let preview = ClipboardRules.preview(for: long)
#expect(preview.count == ClipboardRules.previewLength + 1)
#expect(preview.hasSuffix("…"))
}
@Test("Kurzer Text bleibt unangetastet")
func shortPreviewIsUnchanged() {
#expect(ClipboardRules.preview(for: "kurz") == "kurz")
}
// MARK: - Aufnehmen
@Test("Dasselbe noch einmal kopiert rutscht nach oben, statt sich anzureihen")
func duplicateMovesToFront() {
let existing = [item("a", at: 10), item("b", at: 20)]
let again = item("a", at: 30)
let result = ClipboardRules.merge(existing, adding: again)
#expect(result.items.map(\.fingerprint) == ["a", "b"])
#expect(result.items.first?.addedAt == Date(timeIntervalSince1970: 30))
// Der alte Eintrag ist verdrängt — seine Datei muss weg.
#expect(result.dropped.count == 1)
}
@Test("Über der Grenze fällt das Älteste heraus")
func limitDropsOldest() {
let existing = (0..<5).map { item("f\($0)", at: Double(5 - $0)) }
let result = ClipboardRules.merge(existing, adding: item("neu", at: 99), limit: 3)
#expect(result.items.count == 3)
#expect(result.items.first?.fingerprint == "neu")
// Verdrängtes wird gemeldet, damit die Dateien nicht liegen bleiben.
#expect(result.dropped.count == 3)
}
@Test("Verdrängtes wird vollständig gemeldet, auch die Wiederholung")
func droppedIncludesDuplicate() {
let existing = [item("a", at: 1), item("b", at: 2), item("c", at: 3)]
let result = ClipboardRules.merge(existing, adding: item("a", at: 4), limit: 2)
// „a" einmal als Wiederholung, „c" als das Älteste über der Grenze.
#expect(Set(result.dropped.map(\.fingerprint)) == ["a", "c"])
}
// MARK: - Verfall
@Test("Ohne Frist verfällt nichts")
func noExpiryKeepsEverything() {
let old = [item("a", at: 0)]
#expect(ClipboardRules.expired(old, olderThanDays: nil,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
#expect(ClipboardRules.expired(old, olderThanDays: 0,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
}
@Test("Was älter ist als die Frist, verfällt")
func oldItemsExpire() {
let now = Date(timeIntervalSince1970: 10 * 86_400)
let items = [item("alt", at: 0), item("neu", at: 9 * 86_400)]
let stale = ClipboardRules.expired(items, olderThanDays: 7, now: now)
#expect(stale.map(\.fingerprint) == ["alt"])
}
// MARK: - Reihenfolge
@Test("Neuestes zuerst, und gleich alte in fester Reihenfolge")
func sortingIsStable() {
let a = item("a", at: 5)
let b = item("b", at: 5)
let c = item("c", at: 9)
let once = ClipboardRules.sorted([a, b, c])
let twice = ClipboardRules.sorted([c, b, a])
#expect(once.first?.fingerprint == "c")
// Ohne festen Zweitschlüssel tauschten gleich alte Einträge bei jeder
// Anzeige die Plätze.
#expect(once.map(\.id) == twice.map(\.id))
}
}