wordeck-native/Tests/UnitTests/FSRSSchedulerTests.swift
Till JS fbd758d96e fix(fsrs): 19-Gewichte (FSRS-5) migrieren statt w[20]-Crash
FSRS.init: 19-Element-w wird auf 21 erweitert (FSRS-6-Decay-Defaults),
unerwartete Längen fallen sicher auf defaultW zurück. Verhindert
Index-Crash, falls ein Deck FSRS-5-Settings mit 19 Gewichten liefert.
Test mit 19-Gewichte-Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:05 +02:00

99 lines
3.8 KiB
Swift

import Foundation
import Testing
@testable import WordeckNative
// Step-Felder spiegeln die snake_case-JSON-Keys der Golden-Fixtures.
// swiftlint:disable identifier_name
/// Byte-Paritäts-Tests des Swift-FSRS-Ports gegen echte ts-fsrs-v5.3.2-
/// Outputs (`Fixtures/fsrs_golden.json`, generiert mit `enable_fuzz:false`).
/// Schlägt der Test fehl, weicht die Karten-Planung vom Web ab würde
/// Wiederholungs-Pläne verfälschen.
@Suite("FSRS-Golden-Parität (ts-fsrs v5.3.2 / FSRS-6)")
struct FSRSSchedulerTests {
struct Golden: Decodable {
let base: String
let sequences: [Seq]
}
struct Seq: Decodable {
let ratings: [String]
let steps: [Step]
}
struct Step: Decodable {
let rating: String
let now: String
let due: String
let stability: Double
let difficulty: Double
let elapsed_days: Double
let scheduled_days: Double
let learning_steps: Int
let reps: Int
let lapses: Int
let state: String
}
static func loadGolden() throws -> Golden {
let url = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/fsrs_golden.json")
return try JSONDecoder().decode(Golden.self, from: Data(contentsOf: url))
}
static let ratingMap: [String: FSRSRating] = [
"again": .again, "hard": .hard, "good": .good, "easy": .easy
]
@Test("Alle Golden-Sequenzen reproduzieren ts-fsrs byte-genau")
func goldenParity() throws {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let golden = try Self.loadGolden()
let fsrs = FSRS()
let base = try #require(iso.date(from: golden.base))
for seq in golden.sequences {
var card = FSRSCard.empty(now: base)
for (index, step) in seq.steps.enumerated() {
let now = try #require(iso.date(from: step.now))
let rating = try #require(Self.ratingMap[step.rating])
card = fsrs.grade(card: card, rating: rating, now: now)
let tag = "\(seq.ratings.joined(separator: ","))#\(index)(\(step.rating))"
#expect(card.state.wireName == step.state, "state \(tag)")
#expect(card.reps == step.reps, "reps \(tag)")
#expect(card.lapses == step.lapses, "lapses \(tag)")
#expect(card.learningSteps == step.learning_steps, "learning_steps \(tag)")
#expect(Double(card.elapsedDays) == step.elapsed_days, "elapsed_days \(tag)")
#expect(Double(card.scheduledDays) == step.scheduled_days, "scheduled_days \(tag)")
#expect(
abs(card.stability - step.stability) < 1e-6,
"stability \(tag): \(card.stability) vs \(step.stability)"
)
#expect(
abs(card.difficulty - step.difficulty) < 1e-6,
"difficulty \(tag): \(card.difficulty) vs \(step.difficulty)"
)
let dueExpected = try #require(iso.date(from: step.due))
#expect(
abs(card.due.timeIntervalSince1970 - dueExpected.timeIntervalSince1970) < 0.5,
"due \(tag)"
)
}
}
}
@Test("FSRS-5-Settings (19 Gewichte) crashen nicht, werden migriert")
func fsrs5WeightsMigrateInsteadOfCrash() {
var settings = FSRSSettings()
settings.w = Array(FSRS.defaultW.prefix(19)) // FSRS-5-Format
let fsrs = FSRS(settings)
let next = fsrs.grade(card: .empty(now: Date()), rating: .good, now: Date())
#expect(next.reps == 1)
#expect(next.state != .new)
}
}
// swiftlint:enable identifier_name