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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f289d02c37
commit
3da673662f
@@ -0,0 +1,143 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import FaceProvider
|
||||
|
||||
@Suite("Gesichtsvergleich")
|
||||
struct FaceMatcherTests {
|
||||
|
||||
/// Ein Vektor, der sich um `offset` von der Grundform unterscheidet.
|
||||
private func vector(_ offset: Float, count: Int = 768) -> [Float] {
|
||||
(0..<count).map { Float($0 % 7) * 0.01 + offset }
|
||||
}
|
||||
|
||||
private func matcher(offsets: [Float] = [0],
|
||||
confirmations: Int = 3,
|
||||
attempts: Int = 10) -> FaceMatcher {
|
||||
FaceMatcher(templates: offsets.enumerated().map {
|
||||
FaceTemplate(pose: "p\($0.offset)", vector: vector($0.element))
|
||||
},
|
||||
requiredConfirmations: confirmations,
|
||||
maximumAttempts: attempts)
|
||||
}
|
||||
|
||||
// MARK: - Abstand
|
||||
|
||||
@Test("Derselbe Vektor hat Abstand null")
|
||||
func identicalIsZero() {
|
||||
#expect(FaceMatcher.distance(vector(0), vector(0)) == 0)
|
||||
}
|
||||
|
||||
@Test("Verschieden lange Vektoren ergeben keinen Abstand")
|
||||
func lengthMismatchIsNil() {
|
||||
// Zwei verschieden lange Vektoren zu vergleichen ergäbe eine Zahl ohne
|
||||
// Bedeutung — und die sähe aus wie ein sehr guter Treffer.
|
||||
#expect(FaceMatcher.distance([1, 2, 3], [1, 2]) == nil)
|
||||
#expect(FaceMatcher.distance([], []) == nil)
|
||||
}
|
||||
|
||||
@Test("Der kleinste Abstand über alle Posen zählt")
|
||||
func closestPoseWins() {
|
||||
// Die Posen sind absichtlich verschieden; ein Mittelwert bestrafte
|
||||
// genau die Vielfalt, für die sie aufgenommen wurden.
|
||||
let m = matcher(offsets: [0, 0.5])
|
||||
let d = m.distance(to: vector(0.5))
|
||||
#expect(d == 0)
|
||||
}
|
||||
|
||||
// MARK: - Entscheidung
|
||||
|
||||
@Test("Erst nach genug Bestätigungen wird geöffnet")
|
||||
func needsSeveralConfirmations() {
|
||||
// Die Kamera liefert dreißig Bilder je Sekunde — unter dreißig ist
|
||||
// irgendwann eines dabei, das zufällig passt.
|
||||
let m = matcher(confirmations: 3)
|
||||
var session = FaceMatcher.Session()
|
||||
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 1))
|
||||
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 2))
|
||||
#expect(m.evaluate(vector(0), in: &session) == .accepted)
|
||||
}
|
||||
|
||||
@Test("Ein fremdes Gesicht wird abgelehnt")
|
||||
func strangerIsRejected() {
|
||||
let m = matcher()
|
||||
var session = FaceMatcher.Session()
|
||||
#expect(m.evaluate(vector(5), in: &session) == .undecided(confirmations: 0))
|
||||
#expect(session.attempts == 1)
|
||||
}
|
||||
|
||||
@Test("Ein Fehlversuch setzt die Bestätigungen zurück")
|
||||
func failureResetsConfirmations() {
|
||||
// Sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
|
||||
let m = matcher(confirmations: 3)
|
||||
var session = FaceMatcher.Session()
|
||||
_ = m.evaluate(vector(0), in: &session)
|
||||
_ = m.evaluate(vector(0), in: &session)
|
||||
#expect(session.confirmations == 2)
|
||||
_ = m.evaluate(vector(5), in: &session)
|
||||
#expect(session.confirmations == 0)
|
||||
}
|
||||
|
||||
@Test("Nach genug Fehlversuchen ist gesperrt")
|
||||
func locksOutAfterAttempts() {
|
||||
let m = matcher(attempts: 3)
|
||||
var session = FaceMatcher.Session()
|
||||
_ = m.evaluate(vector(5), in: &session)
|
||||
_ = m.evaluate(vector(5), in: &session)
|
||||
#expect(m.evaluate(vector(5), in: &session) == .lockedOut)
|
||||
}
|
||||
|
||||
@Test("Nach der Sperre hilft auch das richtige Gesicht nicht mehr")
|
||||
func lockoutIsFinal() {
|
||||
// Ab hier bleibt nur das Passwort — sonst wäre die Sperre eine
|
||||
// Verzögerung und keine Grenze.
|
||||
let m = matcher(attempts: 2)
|
||||
var session = FaceMatcher.Session()
|
||||
_ = m.evaluate(vector(5), in: &session)
|
||||
_ = m.evaluate(vector(5), in: &session)
|
||||
#expect(m.evaluate(vector(0), in: &session) == .lockedOut)
|
||||
}
|
||||
|
||||
@Test("Ohne hinterlegtes Gesicht öffnet nichts")
|
||||
func noEnrollmentNeverOpens() {
|
||||
// Ein leerer Vergleich darf kein Freifahrtschein sein.
|
||||
let m = FaceMatcher(templates: [])
|
||||
var session = FaceMatcher.Session()
|
||||
#expect(!m.isEnrolled)
|
||||
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 0))
|
||||
#expect(session.attempts == 1)
|
||||
}
|
||||
|
||||
@Test("Ein Vektor falscher Länge gilt als Fehlversuch, nicht als Treffer")
|
||||
func wrongLengthIsAFailure() {
|
||||
let m = matcher()
|
||||
var session = FaceMatcher.Session()
|
||||
#expect(m.evaluate([1, 2, 3], in: &session) == .undecided(confirmations: 0))
|
||||
#expect(session.attempts == 1)
|
||||
}
|
||||
|
||||
@Test("Die Schwelle liegt näher am Ähnlichen als am Verschiedenen")
|
||||
func thresholdLeansStrict() {
|
||||
// Aus dem Spike: ähnlich 0,13 — verschieden 0,47. Ein falsches Ja ist
|
||||
// ein Fremder am entsperrten Rechner, ein falsches Nein nur ein
|
||||
// Passwort, das man ohnehin kennt.
|
||||
#expect(FaceMatcher.defaultThreshold < (0.13 + 0.47) / 2)
|
||||
#expect(FaceMatcher.defaultThreshold > 0.13)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import NotesProvider
|
||||
|
||||
@Suite("Notizen: die Regeln")
|
||||
struct NoteRulesTests {
|
||||
|
||||
@Test("Die Überschrift ist die erste nichtleere Zeile")
|
||||
func titleIsFirstLine() {
|
||||
let note = Note(text: "\n\n Einkaufen \nMilch\nBrot")
|
||||
#expect(NoteRules.title(of: note) == "Einkaufen")
|
||||
}
|
||||
|
||||
@Test("Eine zu lange erste Zeile wird gekürzt")
|
||||
func longTitleIsTruncated() {
|
||||
let note = Note(text: String(repeating: "a", count: 200))
|
||||
let title = NoteRules.title(of: note)
|
||||
#expect(title.count == NoteRules.titleLength + 1)
|
||||
#expect(title.hasSuffix("…"))
|
||||
}
|
||||
|
||||
@Test("Eine leere Notiz hat keine Überschrift")
|
||||
func emptyHasNoTitle() {
|
||||
#expect(NoteRules.title(of: Note(text: " \n\n ")) == "")
|
||||
}
|
||||
|
||||
@Test("Eine Notiz aus lauter Leerzeichen zählt als leer")
|
||||
func whitespaceIsEmpty() {
|
||||
// Sie entsteht, wenn man auf „Neu" drückt und es sich anders überlegt.
|
||||
#expect(NoteRules.isEmpty(Note(text: " \n\t ")))
|
||||
#expect(!NoteRules.isEmpty(Note(text: "x")))
|
||||
}
|
||||
|
||||
@Test("Eine leer gewordene Notiz verschwindet beim Speichern")
|
||||
func emptyNoteIsDropped() {
|
||||
let note = Note(text: "war mal etwas")
|
||||
let kept = NoteRules.merge([], updating: note)
|
||||
#expect(kept.count == 1)
|
||||
|
||||
var emptied = note
|
||||
emptied.text = " "
|
||||
#expect(NoteRules.merge(kept, updating: emptied).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Zuletzt geändert steht oben")
|
||||
func newestFirst() {
|
||||
let old = Note(text: "alt", updatedAt: Date(timeIntervalSince1970: 1))
|
||||
let new = Note(text: "neu", updatedAt: Date(timeIntervalSince1970: 2))
|
||||
#expect(NoteRules.sorted([old, new]).first?.text == "neu")
|
||||
}
|
||||
|
||||
@Test("Gleich alte Notizen behalten ihre Reihenfolge")
|
||||
func sortingIsStable() {
|
||||
let when = Date(timeIntervalSince1970: 5)
|
||||
let a = Note(text: "a", updatedAt: when)
|
||||
let b = Note(text: "b", updatedAt: when)
|
||||
#expect(NoteRules.sorted([a, b]).map(\.id) == NoteRules.sorted([b, a]).map(\.id))
|
||||
}
|
||||
|
||||
@Test("Eine bestehende Notiz wird ersetzt, nicht verdoppelt")
|
||||
func updateReplaces() {
|
||||
let note = Note(text: "erst")
|
||||
var changed = note
|
||||
changed.text = "dann"
|
||||
let result = NoteRules.merge([note], updating: changed)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.text == "dann")
|
||||
}
|
||||
|
||||
@Test("Über der Grenze fällt die älteste heraus")
|
||||
func limitDropsOldest() {
|
||||
let existing = (0..<3).map {
|
||||
Note(text: "n\($0)", updatedAt: Date(timeIntervalSince1970: Double($0)))
|
||||
}
|
||||
let result = NoteRules.merge(existing,
|
||||
updating: Note(text: "neu",
|
||||
updatedAt: Date(timeIntervalSince1970: 99)),
|
||||
limit: 3)
|
||||
#expect(result.count == 3)
|
||||
#expect(result.first?.text == "neu")
|
||||
#expect(!result.contains { $0.text == "n0" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user