diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index a97153a..0846ef0 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -140,8 +140,8 @@ struct PracticeSessionService { return nil } - /// Returns a `FullTablePrompt` if this verb's forms in the given tense are - /// all marked `regular` and complete. Nil otherwise. + /// Returns a `FullTablePrompt` if this verb's forms in the given tense + /// follow the regular pattern (per `FullTableEligibility`). Nil otherwise. private func makePromptIfFullyRegular( verb: Verb, tenseId: String, @@ -149,8 +149,12 @@ struct PracticeSessionService { ) -> FullTablePrompt? { let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId) guard !forms.isEmpty else { return nil } - if forms.contains(where: { $0.regularity != "regular" }) { return nil } - return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) + // Forms must arrive in personIndex order so the regularity array lines + // up. `fetchForms` already sorts them, but assert for safety. + let sorted = forms.sorted { $0.personIndex < $1.personIndex } + let regularities = sorted.map { $0.regularity } + guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil } + return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted) } func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] { diff --git a/Conjuga/SharedModels/Sources/SharedModels/FullTableEligibility.swift b/Conjuga/SharedModels/Sources/SharedModels/FullTableEligibility.swift new file mode 100644 index 0000000..7c2d9b3 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/FullTableEligibility.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Decides whether a (verb, tense) combo is eligible for the Full Table +/// practice mode. Pulled out of `PracticeSessionService` so the rule can be +/// unit tested in isolation. +public enum FullTableEligibility { + + /// `VerbForm.regularity` values understood by the data set. + public enum Regularity: String, Sendable { + case regular // paradigm-teaching verbs (small curated set) + case ordinary // pattern-following verbs (the bulk of the dataset) + case irregular + case orto // orthographic spelling change (e.g. busqué) + } + + /// Returns true when the given (verb, tense) combo is a candidate for + /// Full Table — i.e. it follows the regular pattern with no irregularity + /// and no orthographic spelling change. + /// + /// The conjugation must be present in all 6 person slots; missing forms + /// disqualify the combo. + /// + /// Accepted: `regular` (paradigm-teaching verbs) and `ordinary` + /// (pattern-following verbs — `hablar`, `comer`, `vivir`, etc.). + /// Rejected: `irregular` and `orto` (orthographic spelling changes). + public static func isFullyRegular(regularities: [String]) -> Bool { + guard regularities.count == 6 else { return false } + let acceptable: Set = ["regular", "ordinary"] + return regularities.allSatisfy { acceptable.contains($0) } + } +} diff --git a/Conjuga/SharedModels/Tests/SharedModelsTests/FullTableEligibilityTests.swift b/Conjuga/SharedModels/Tests/SharedModelsTests/FullTableEligibilityTests.swift new file mode 100644 index 0000000..7eff412 --- /dev/null +++ b/Conjuga/SharedModels/Tests/SharedModelsTests/FullTableEligibilityTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import SharedModels + +@Suite("FullTableEligibility") +struct FullTableEligibilityTests { + + // MARK: - Should be eligible + + @Test("all-ordinary verb (e.g. hablar present) is eligible") + func allOrdinary() { + // hablar present: hablo / hablas / habla / hablamos / habláis / hablan + let r = Array(repeating: "ordinary", count: 6) + #expect(FullTableEligibility.isFullyRegular(regularities: r)) + } + + @Test("all-regular paradigm verb (e.g. vivir present) is eligible") + func allRegular() { + // vivir present is tagged "regular" in the curated subset + let r = Array(repeating: "regular", count: 6) + #expect(FullTableEligibility.isFullyRegular(regularities: r)) + } + + @Test("mixed regular + ordinary across persons is eligible") + func mixedRegularOrdinary() { + // The data never actually mixes these, but it shouldn't matter if it + // did — both labels mean "follows the regular pattern". + let r = ["regular", "ordinary", "regular", "ordinary", "regular", "ordinary"] + #expect(FullTableEligibility.isFullyRegular(regularities: r)) + } + + // MARK: - Should NOT be eligible + + @Test("any irregular form rejects the combo") + func anyIrregular() { + var r = Array(repeating: "ordinary", count: 6) + r[3] = "irregular" + #expect(!FullTableEligibility.isFullyRegular(regularities: r)) + } + + @Test("orthographic spelling change rejects the combo (excluded by design)") + func anyOrto() { + // buscar preterite: yo "busqué" carries an orto tag for the c→qu shift. + // Per design choice, orto verbs are NOT eligible for Full Table. + var r = Array(repeating: "ordinary", count: 6) + r[0] = "orto" + #expect(!FullTableEligibility.isFullyRegular(regularities: r)) + } + + @Test("all-irregular rejects") + func allIrregular() { + let r = Array(repeating: "irregular", count: 6) + #expect(!FullTableEligibility.isFullyRegular(regularities: r)) + } + + // MARK: - Edge cases + + @Test("incomplete forms (fewer than 6 persons) are rejected") + func incompleteForms() { + let r = Array(repeating: "ordinary", count: 5) + #expect(!FullTableEligibility.isFullyRegular(regularities: r)) + } + + @Test("empty input is rejected") + func empty() { + #expect(!FullTableEligibility.isFullyRegular(regularities: [])) + } + + @Test("unknown regularity value is rejected (defensive default)") + func unknownValue() { + var r = Array(repeating: "ordinary", count: 6) + r[2] = "garbage_value" + #expect(!FullTableEligibility.isFullyRegular(regularities: r)) + } +}