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:
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure practice-pool filtering (Issue #26).
|
||||
///
|
||||
/// Takes plain value snapshots of the verb + irregular-span data and computes
|
||||
/// the set of verb IDs eligible for practice under the user's selected filters.
|
||||
/// Deliberately decoupled from SwiftData so the same logic is directly testable
|
||||
/// without a ModelContainer.
|
||||
public enum PracticeFilter {
|
||||
|
||||
/// Minimal verb snapshot for filtering.
|
||||
public struct VerbSlot: Sendable, Hashable {
|
||||
public let id: Int
|
||||
public let level: String
|
||||
public init(id: Int, level: String) {
|
||||
self.id = id
|
||||
self.level = level
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal irregular-span snapshot for filtering.
|
||||
public struct IrregularSlot: Sendable, Hashable {
|
||||
public let verbId: Int
|
||||
public let category: IrregularSpan.SpanCategory
|
||||
public init(verbId: Int, category: IrregularSpan.SpanCategory) {
|
||||
self.verbId = verbId
|
||||
self.category = category
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of `VerbLevelGroup.dataLevels(for:)` across every user-facing level.
|
||||
/// An empty input produces an empty result; callers decide the empty semantics.
|
||||
public static func dataLevels(forSelectedLevels levels: Set<String>) -> Set<String> {
|
||||
levels.reduce(into: Set<String>()) { acc, level in
|
||||
acc.formUnion(VerbLevelGroup.dataLevels(for: level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verb IDs whose `level` falls inside any of the selected level groups.
|
||||
public static func verbIDs(
|
||||
matchingLevels selectedLevels: Set<String>,
|
||||
in verbs: [VerbSlot]
|
||||
) -> Set<Int> {
|
||||
guard !selectedLevels.isEmpty else { return [] }
|
||||
let expanded = dataLevels(forSelectedLevels: selectedLevels)
|
||||
return Set(verbs.filter { expanded.contains($0.level) }.map(\.id))
|
||||
}
|
||||
|
||||
/// Verb IDs that have at least one irregular span in the requested categories.
|
||||
/// Returns an empty set when `categories` is empty — caller decides whether
|
||||
/// that means "no constraint" or "no matches".
|
||||
public static func verbIDs(
|
||||
matchingIrregularCategories categories: Set<IrregularSpan.SpanCategory>,
|
||||
in spans: [IrregularSlot]
|
||||
) -> Set<Int> {
|
||||
guard !categories.isEmpty else { return [] }
|
||||
var ids = Set<Int>()
|
||||
for slot in spans where categories.contains(slot.category) {
|
||||
ids.insert(slot.verbId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// Practice pool: verbs at the selected levels, intersected with irregular
|
||||
/// categories when that filter is active.
|
||||
///
|
||||
/// Semantics (Issue #26):
|
||||
/// - `selectedLevels` empty → empty pool (literal).
|
||||
/// - `irregularCategories` empty → no irregular constraint (all verbs at level).
|
||||
public static func allowedVerbIDs(
|
||||
verbs: [VerbSlot],
|
||||
spans: [IrregularSlot],
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> Set<Int> {
|
||||
let levelIDs = verbIDs(matchingLevels: selectedLevels, in: verbs)
|
||||
guard !irregularCategories.isEmpty else { return levelIDs }
|
||||
let irregularIDs = verbIDs(matchingIrregularCategories: irregularCategories, in: spans)
|
||||
return levelIDs.intersection(irregularIDs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
public extension VerbLevel {
|
||||
/// The highest-ranked `VerbLevel` in `set` per `allCases` ordering.
|
||||
/// Used when a single representative level is required (word-of-day
|
||||
/// widget, AI chat/story scenario generation).
|
||||
static func highest(in set: Set<VerbLevel>) -> VerbLevel? {
|
||||
allCases.last { set.contains($0) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user