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>
99 lines
3.8 KiB
Swift
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
|