diff --git a/Conjuga/Conjuga/Models/UserProgress.swift b/Conjuga/Conjuga/Models/UserProgress.swift index 6b0b188..1d34f35 100644 --- a/Conjuga/Conjuga/Models/UserProgress.swift +++ b/Conjuga/Conjuga/Models/UserProgress.swift @@ -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 { + 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 { + 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 } + } diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index 71d2763..637db72 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -4,13 +4,18 @@ import SwiftData struct PracticeSettings: Sendable { let selectedLevel: String + let selectedLevels: Set let enabledTenses: Set + let enabledIrregularCategories: Set 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( predicate: #Predicate { $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( predicate: #Predicate { $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 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 diff --git a/Conjuga/Conjuga/Services/ReferenceStore.swift b/Conjuga/Conjuga/Services/ReferenceStore.swift index cd23e5e..baed00c 100644 --- a/Conjuga/Conjuga/Services/ReferenceStore.swift +++ b/Conjuga/Conjuga/Services/ReferenceStore.swift @@ -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) -> [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, + irregularCategories: Set + ) -> Set { + 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, + irregularCategories: Set + ) -> [Verb] { + let ids = allowedVerbIDs( + selectedLevels: selectedLevels, + irregularCategories: irregularCategories + ) + return fetchVerbs().filter { ids.contains($0.id) } + } + + private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] { + let descriptor = FetchDescriptor() + let spans = (try? context.fetch(descriptor)) ?? [] + return spans.map { .init(verbId: $0.verbId, category: $0.category) } + } + func fetchVerb(id: Int) -> Verb? { let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) return (try? context.fetch(descriptor))?.first diff --git a/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift b/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift index fc3324f..1acfb4e 100644 --- a/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift +++ b/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift @@ -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() } diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index 56fcffc..f1c0006 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -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() { diff --git a/Conjuga/SharedModels/Sources/SharedModels/PracticeFilter.swift b/Conjuga/SharedModels/Sources/SharedModels/PracticeFilter.swift new file mode 100644 index 0000000..4a4e88b --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/PracticeFilter.swift @@ -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) -> Set { + levels.reduce(into: Set()) { 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, + in verbs: [VerbSlot] + ) -> Set { + 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, + in spans: [IrregularSlot] + ) -> Set { + guard !categories.isEmpty else { return [] } + var ids = Set() + 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, + irregularCategories: Set + ) -> Set { + let levelIDs = verbIDs(matchingLevels: selectedLevels, in: verbs) + guard !irregularCategories.isEmpty else { return levelIDs } + let irregularIDs = verbIDs(matchingIrregularCategories: irregularCategories, in: spans) + return levelIDs.intersection(irregularIDs) + } +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/VerbLevel+Highest.swift b/Conjuga/SharedModels/Sources/SharedModels/VerbLevel+Highest.swift new file mode 100644 index 0000000..14f7973 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/VerbLevel+Highest.swift @@ -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? { + allCases.last { set.contains($0) } + } +} diff --git a/Conjuga/SharedModels/Tests/SharedModelsTests/PracticeFilterTests.swift b/Conjuga/SharedModels/Tests/SharedModelsTests/PracticeFilterTests.swift new file mode 100644 index 0000000..6d4afb7 --- /dev/null +++ b/Conjuga/SharedModels/Tests/SharedModelsTests/PracticeFilterTests.swift @@ -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) + } +}