CardRenderer für typing ist nicht mehr Placeholder. Web-Vorbild: TypingView.svelte + cards-domain/typing.ts. Typing.swift (Sources/Core/Domain/): - check(input:answer:aliases:) → TypingMatch (correct/close/wrong) - Normalisierung: trim + lowercase + NFD-Decomp + Combining-Marks strippen (Diakritika: ä → a) - Aliases-Support (Komma-getrennt aus card.fields["aliases"]) - Levenshtein-Threshold max(1, floor(len * 0.2)) → "close" TypingCardView (Features/Study/): - TextField mit Auto-Focus 0.15s nach onAppear, Return = Submit - Submit-Button mit Return-Symbol + primary background - Nach Submit: Badge (✓ Richtig / ≈ Fast / ✗ Falsch) + User- Eingabe in „…" Quotes + Divider + erwartete Antwort - Haptic-Feedback: heavy bei correct, light bei close/wrong - Reset on card.id change TypingTests: 8 Tests für check() — exact, case+whitespace, NFD-Umlauts, aliases, Levenshtein-close (Berln → Berlin), empty-input, sowie Levenshtein-Helper-Sanity. Build 8 → 9. 43 Tests grün (war 35). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
2.7 KiB
Swift
77 lines
2.7 KiB
Swift
import Foundation
|
|
|
|
/// Vergleich einer getippten User-Antwort gegen die erwartete Antwort.
|
|
/// 1:1-Port aus `cards/packages/cards-domain/src/typing.ts`:
|
|
/// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping),
|
|
/// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit
|
|
/// Threshold `max(1, floor(answer.length * 0.2))` → `close`.
|
|
enum TypingMatch: Sendable, Equatable {
|
|
case correct
|
|
case close
|
|
case wrong
|
|
}
|
|
|
|
enum Typing {
|
|
/// `aliases` ist ein Komma-getrennter String aus dem `aliases`-Feld
|
|
/// der Karte (optional). Jeder Alias zählt als gültige Antwort.
|
|
static func check(input: String, answer: String, aliases: String? = nil) -> TypingMatch {
|
|
let normInput = normalize(input)
|
|
guard !normInput.isEmpty else { return .wrong }
|
|
|
|
var candidates = [answer]
|
|
if let aliases {
|
|
candidates.append(contentsOf: aliases.split(separator: ",").map(String.init))
|
|
}
|
|
let normalizedCandidates = candidates
|
|
.map(normalize)
|
|
.filter { !$0.isEmpty }
|
|
guard !normalizedCandidates.isEmpty else { return .wrong }
|
|
|
|
if normalizedCandidates.contains(normInput) {
|
|
return .correct
|
|
}
|
|
|
|
let shortestLen = normalizedCandidates.map(\.count).min() ?? normInput.count
|
|
let threshold = max(1, Int(Double(shortestLen) * 0.2))
|
|
for candidate in normalizedCandidates where levenshtein(normInput, candidate) <= threshold {
|
|
return .close
|
|
}
|
|
return .wrong
|
|
}
|
|
|
|
private static func normalize(_ string: String) -> String {
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let lowered = trimmed.lowercased()
|
|
// NFD-Dekomposition + Combining-Marks entfernen (z.B. ä → a)
|
|
let decomposed = lowered.decomposedStringWithCanonicalMapping
|
|
let stripped = decomposed.unicodeScalars.filter { scalar in
|
|
!(0x0300 ... 0x036F).contains(scalar.value)
|
|
}
|
|
return String(String.UnicodeScalarView(stripped))
|
|
}
|
|
|
|
static func levenshtein(_ a: String, _ b: String) -> Int {
|
|
let aChars = Array(a)
|
|
let bChars = Array(b)
|
|
let m = aChars.count
|
|
let n = bChars.count
|
|
if m == 0 { return n }
|
|
if n == 0 { return m }
|
|
|
|
var row = Array(0 ... n)
|
|
for i in 1 ... m {
|
|
var prev = row[0]
|
|
row[0] = i
|
|
for j in 1 ... n {
|
|
let tmp = row[j]
|
|
if aChars[i - 1] == bChars[j - 1] {
|
|
row[j] = prev
|
|
} else {
|
|
row[j] = 1 + Swift.min(prev, row[j], row[j - 1])
|
|
}
|
|
prev = tmp
|
|
}
|
|
}
|
|
return row[n]
|
|
}
|
|
}
|