cards-native/Sources/Core/Domain/Typing.swift
Till JS 505aa9db19 feat(study): Typing-Karten + Levenshtein-Match-Logik
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>
2026-05-13 17:39:39 +02:00

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]
}
}