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

@@ -21,6 +21,10 @@ final class UserProgress {
var enabledTensesBlob: String = ""
var unlockedBadgesBlob: String = ""
// Multi-select level + irregularity filters (Issue #26).
var selectedLevelsBlob: String = ""
var enabledIrregularCategoriesBlob: String = ""
init() {}
var selectedVerbLevel: VerbLevel {
@@ -44,6 +48,44 @@ final class UserProgress {
}
}
/// Levels currently enabled for practice. Multi-select per Issue #26.
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
var selectedVerbLevels: Set<VerbLevel> {
get {
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
if !decoded.isEmpty { return decoded }
// Pre-migration users: treat the single selectedLevel as the set.
if let legacy = VerbLevel(rawValue: selectedLevel) {
return [legacy]
}
return []
}
set {
let sorted = newValue.map(\.rawValue)
selectedLevelsBlob = Self.encodeStringArray(sorted)
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
}
}
/// The single representative level for callers that need one value
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
var primaryLevel: VerbLevel {
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
}
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
get {
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
}
set {
let sorted = newValue.map(\.rawValue)
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
}
}
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
var values = Set(enabledTenseIDs)
if enabled {
@@ -54,6 +96,26 @@ final class UserProgress {
enabledTenseIDs = values.sorted()
}
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
var values = selectedVerbLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
selectedVerbLevels = values
}
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
var values = enabledIrregularCategories
if enabled {
values.insert(category)
} else {
values.remove(category)
}
enabledIrregularCategories = values
}
func unlockBadge(_ badgeId: String) {
var values = Set(unlockedBadgeIDs)
values.insert(badgeId)
@@ -67,6 +129,9 @@ final class UserProgress {
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
unlockedBadgeIDs = unlockedBadges
}
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
selectedVerbLevels = [legacy]
}
}
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
@@ -86,4 +151,5 @@ final class UserProgress {
}
return string
}
}

View File

@@ -4,13 +4,18 @@ import SwiftData
struct PracticeSettings: Sendable {
let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool
init(progress: UserProgress?) {
let resolved = progress?.enabledTenseIDs ?? []
let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.enabledTenses = Set(resolved)
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true
}
@@ -79,7 +84,9 @@ struct PracticeSessionService {
func randomFullTablePrompt() -> FullTablePrompt? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
// Full Table practice is regular-only, so the irregular-category setting is
// deliberately ignored here (applying it would empty the pool).
let verbs = referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels)
guard !verbs.isEmpty else { return nil }
for _ in 0..<40 {
@@ -157,7 +164,10 @@ struct PracticeSessionService {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let allowedVerbIds = referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
)
let now = Date()
var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
@@ -184,7 +194,10 @@ struct PracticeSessionService {
private func pickWeakForm() -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let allowedVerbIds = referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
)
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
@@ -206,7 +219,12 @@ struct PracticeSessionService {
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
// Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: []
)
let typeRange: ClosedRange<Int>
switch filter {
@@ -243,7 +261,10 @@ struct PracticeSessionService {
private func pickCommonTenseForm() -> VerbForm? {
let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
let verbs = referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -256,7 +277,10 @@ struct PracticeSessionService {
private func pickRandomForm() -> VerbForm? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
let verbs = referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in

View File

@@ -27,6 +27,50 @@ struct ReferenceStore {
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
}
/// Union of data-levels for all selected user-facing levels.
/// Empty input produces an empty result callers decide how to handle that.
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
guard !selectedLevels.isEmpty else { return [] }
let ids = PracticeFilter.verbIDs(
matchingLevels: selectedLevels,
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
)
return fetchVerbs().filter { ids.contains($0.id) }
}
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
/// in SharedModels without a ModelContainer (Issue #26).
func allowedVerbIDs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> Set<Int> {
PracticeFilter.allowedVerbIDs(
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
spans: allIrregularSlots(),
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
}
/// Convenience: full Verb objects passing both filters.
func fetchVerbs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> [Verb] {
let ids = allowedVerbIDs(
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
return fetchVerbs().filter { ids.contains($0.id) }
}
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
let descriptor = FetchDescriptor<IrregularSpan>()
let spans = (try? context.fetch(descriptor)) ?? []
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
}
func fetchVerb(id: Int) -> Verb? {
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
return (try? context.fetch(descriptor))?.first

View File

@@ -128,7 +128,7 @@ struct OnboardingView: View {
private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress.selectedVerbLevel = selectedLevel
progress.selectedVerbLevels = [selectedLevel]
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
}

View File

@@ -9,9 +9,11 @@ struct SettingsView: View {
@State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false
@State private var selectedLevel: VerbLevel = .basic
private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular
]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
@@ -40,19 +42,26 @@ struct SettingsView: View {
}
}
Section("Level") {
Picker("Current Level", selection: $selectedLevel) {
ForEach(levels, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.onChange(of: selectedLevel) { _, newValue in
progress?.selectedVerbLevel = newValue
saveProgress()
Section {
ForEach(levels, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
get: {
progress?.selectedVerbLevels.contains(level) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Levels")
} footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
Section("Tenses") {
Section {
ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding(
get: {
@@ -65,6 +74,27 @@ struct SettingsView: View {
}
))
}
} header: {
Text("Tenses")
}
Section {
ForEach(irregularCategories, id: \.self) { category in
Toggle(category.rawValue, isOn: Binding(
get: {
progress?.enabledIrregularCategories.contains(category) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setIrregularCategoryEnabled(category, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Irregular Types")
} footer: {
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
}
Section("Stats") {
@@ -96,7 +126,6 @@ struct SettingsView: View {
dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem
selectedLevel = resolved.selectedVerbLevel
}
private func saveProgress() {

View File

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

View File

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

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