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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user