Fixes #26 — Multi-select levels and irregular-type practice filter

UserProgress gains selectedLevelsBlob and enabledIrregularCategoriesBlob
(mirrors the existing tense-blob pattern). The multi-level setter keeps the
legacy selectedLevel String in sync with the highest-ranked selection, so
widget sync, AI scenarios, and achievement checks keep working unchanged.
Legacy single-level users are migrated on first read.

Settings replaces the level Picker with per-level toggles and adds an
Irregular Types section with three toggles. Practice pool is the literal
intersection: empty levels means zero results, empty irregular categories
means no irregularity constraint.

Pure filter logic lives in SharedModels (PracticeFilter, VerbLevel.highest)
and is covered by 20 Swift Testing cases. ReferenceStore delegates so the
intersection behavior is unit-tested without a ModelContainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-22 09:36:25 -05:00
parent 3d8cbccc4e
commit 5d3accb2c0
8 changed files with 487 additions and 21 deletions

View File

@@ -0,0 +1,212 @@
import Testing
@testable import SharedModels
// Practice pool = selected levels selected tenses selected irregular types
// (Issue #26). This suite covers the level + irregular intersection; tense
// filtering is a separate concern handled at the review-card layer.
@Suite("PracticeFilter — level selection")
struct PracticeFilterLevelTests {
@Test("single level expands to that group's data-levels")
func singleLevelExpansion() {
let expected = VerbLevelGroup.dataLevels(for: "elementary")
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["elementary"]) == expected)
}
@Test("multi-level union merges every group's data-levels")
func multiLevelUnion() {
let union = PracticeFilter.dataLevels(forSelectedLevels: ["elementary", "intermediate"])
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "elementary")))
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "intermediate")))
}
@Test("empty selected-levels produces empty data-levels")
func emptyLevelsProducesEmpty() {
#expect(PracticeFilter.dataLevels(forSelectedLevels: []).isEmpty)
}
@Test("unknown level passes through to its raw value")
func unknownLevelPassthrough() {
// Preserves VerbLevelGroup's fallback contract.
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["custom"]) == ["custom"])
}
}
@Suite("PracticeFilter — verb IDs by level")
struct PracticeFilterVerbLevelTests {
// Fixtures: four verbs spanning basic + elementary subgroups + intermediate.
private let verbs: [PracticeFilter.VerbSlot] = [
.init(id: 1, level: "basic"),
.init(id: 2, level: "elementary"),
.init(id: 3, level: "elementary_2"),
.init(id: 4, level: "intermediate_1"),
]
@Test("elementary selection matches elementary base and subgroup levels")
func elementarySubgroupMatch() {
let ids = PracticeFilter.verbIDs(matchingLevels: ["elementary"], in: verbs)
#expect(ids == [2, 3])
}
@Test("multi-level selection unions matching verb IDs")
func multiLevelUnionIds() {
let ids = PracticeFilter.verbIDs(matchingLevels: ["basic", "intermediate"], in: verbs)
#expect(ids == [1, 4])
}
@Test("empty level selection returns no verbs (literal semantics)")
func emptySelectionReturnsEmpty() {
let ids = PracticeFilter.verbIDs(matchingLevels: [], in: verbs)
#expect(ids.isEmpty)
}
}
@Suite("PracticeFilter — verb IDs by irregular category")
struct PracticeFilterIrregularTests {
// Verb 10: regular (no spans).
// Verb 20: one spelling-change span.
// Verb 30: stem-change + unique-irregular spans.
private let spans: [PracticeFilter.IrregularSlot] = [
.init(verbId: 20, category: .spelling),
.init(verbId: 30, category: .stemChange),
.init(verbId: 30, category: .uniqueIrregular),
]
@Test("empty category set returns empty — caller decides the semantics")
func emptyCategoriesReturnsEmpty() {
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [], in: spans)
#expect(ids.isEmpty)
}
@Test("single category picks matching verbs only")
func singleCategoryMatch() {
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [.spelling], in: spans)
#expect(ids == [20])
}
@Test("multiple categories union their matches")
func multipleCategoriesUnion() {
let ids = PracticeFilter.verbIDs(
matchingIrregularCategories: [.spelling, .stemChange],
in: spans
)
#expect(ids == [20, 30])
}
@Test("a verb with multiple matching spans is returned once")
func verbWithMultipleSpansDeduped() {
let ids = PracticeFilter.verbIDs(
matchingIrregularCategories: [.stemChange, .uniqueIrregular],
in: spans
)
#expect(ids == [30])
}
}
@Suite("PracticeFilter — allowedVerbIDs (levels ∩ irregulars)")
struct PracticeFilterIntersectionTests {
// Realistic fixture:
// #1 basic, regular
// #2 basic, spelling-change
// #3 elementary, spelling-change
// #4 elementary, stem-change
// #5 intermediate, unique-irregular
private let verbs: [PracticeFilter.VerbSlot] = [
.init(id: 1, level: "basic"),
.init(id: 2, level: "basic"),
.init(id: 3, level: "elementary"),
.init(id: 4, level: "elementary_1"),
.init(id: 5, level: "intermediate"),
]
private let spans: [PracticeFilter.IrregularSlot] = [
.init(verbId: 2, category: .spelling),
.init(verbId: 3, category: .spelling),
.init(verbId: 4, category: .stemChange),
.init(verbId: 5, category: .uniqueIrregular),
]
@Test("no irregular filter keeps every verb at the selected level")
func noIrregularConstraint() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: []
)
#expect(ids == [1, 2])
}
@Test("Issue #26 worked example: beginner + spelling-change → only #2")
func issueWorkedExample() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: [.spelling]
)
#expect(ids == [2])
}
@Test("filter is an intersection, not a union: level-mismatched spans are excluded")
func intersectionExcludesOtherLevels() {
// Elementary has a spelling-change verb (#3). Selecting basic + spelling
// must NOT leak #3 through the irregular filter alone.
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: [.spelling]
)
#expect(!ids.contains(3))
}
@Test("empty level selection produces empty pool regardless of irregular filter")
func emptyLevelsLocksOutPractice() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: [],
irregularCategories: [.spelling]
)
#expect(ids.isEmpty)
}
@Test("multi-level + multi-category pulls every matching pair")
func multiLevelMultiCategory() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["elementary", "intermediate"],
irregularCategories: [.stemChange, .uniqueIrregular]
)
#expect(ids == [4, 5])
}
}
@Suite("VerbLevel.highest")
struct VerbLevelHighestTests {
@Test("returns the highest-ranked level in the set")
func highestOfMany() {
#expect(VerbLevel.highest(in: [.basic, .intermediate, .elementary]) == .intermediate)
}
@Test("returns the sole element when set is a singleton")
func singleton() {
#expect(VerbLevel.highest(in: [.advanced]) == .advanced)
}
@Test("returns nil for the empty set")
func empty() {
#expect(VerbLevel.highest(in: []) == nil)
}
@Test("ranks expert above advanced above intermediate above elementary above basic")
func fullRanking() {
#expect(VerbLevel.highest(in: [.basic, .elementary, .intermediate, .advanced, .expert]) == .expert)
}
}