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
@@ -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) }
}
}