Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d3accb2c0 | |||
| 3d8cbccc4e |
@@ -21,6 +21,10 @@ final class UserProgress {
|
|||||||
var enabledTensesBlob: String = ""
|
var enabledTensesBlob: String = ""
|
||||||
var unlockedBadgesBlob: String = ""
|
var unlockedBadgesBlob: String = ""
|
||||||
|
|
||||||
|
// Multi-select level + irregularity filters (Issue #26).
|
||||||
|
var selectedLevelsBlob: String = ""
|
||||||
|
var enabledIrregularCategoriesBlob: String = ""
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
var selectedVerbLevel: VerbLevel {
|
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) {
|
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
||||||
var values = Set(enabledTenseIDs)
|
var values = Set(enabledTenseIDs)
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -54,6 +96,26 @@ final class UserProgress {
|
|||||||
enabledTenseIDs = values.sorted()
|
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) {
|
func unlockBadge(_ badgeId: String) {
|
||||||
var values = Set(unlockedBadgeIDs)
|
var values = Set(unlockedBadgeIDs)
|
||||||
values.insert(badgeId)
|
values.insert(badgeId)
|
||||||
@@ -67,6 +129,9 @@ final class UserProgress {
|
|||||||
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
||||||
unlockedBadgeIDs = unlockedBadges
|
unlockedBadgeIDs = unlockedBadges
|
||||||
}
|
}
|
||||||
|
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||||
|
selectedVerbLevels = [legacy]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
||||||
@@ -86,4 +151,5 @@ final class UserProgress {
|
|||||||
}
|
}
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import SwiftData
|
|||||||
|
|
||||||
struct PracticeSettings: Sendable {
|
struct PracticeSettings: Sendable {
|
||||||
let selectedLevel: String
|
let selectedLevel: String
|
||||||
|
let selectedLevels: Set<String>
|
||||||
let enabledTenses: Set<String>
|
let enabledTenses: Set<String>
|
||||||
|
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||||
let showVosotros: Bool
|
let showVosotros: Bool
|
||||||
|
|
||||||
init(progress: UserProgress?) {
|
init(progress: UserProgress?) {
|
||||||
let resolved = progress?.enabledTenseIDs ?? []
|
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||||
|
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
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
|
self.showVosotros = progress?.showVosotros ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +84,9 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||||
let settings = settings()
|
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 }
|
guard !verbs.isEmpty else { return nil }
|
||||||
|
|
||||||
for _ in 0..<40 {
|
for _ in 0..<40 {
|
||||||
@@ -157,7 +164,10 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: settings.enabledIrregularCategories
|
||||||
|
)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
var descriptor = FetchDescriptor<ReviewCard>(
|
var descriptor = FetchDescriptor<ReviewCard>(
|
||||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
||||||
@@ -184,7 +194,10 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
private func pickWeakForm() -> VerbForm? {
|
private func pickWeakForm() -> VerbForm? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: settings.enabledIrregularCategories
|
||||||
|
)
|
||||||
|
|
||||||
let descriptor = FetchDescriptor<ReviewCard>(
|
let descriptor = FetchDescriptor<ReviewCard>(
|
||||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||||
@@ -206,7 +219,12 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
||||||
let settings = settings()
|
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>
|
let typeRange: ClosedRange<Int>
|
||||||
|
|
||||||
switch filter {
|
switch filter {
|
||||||
@@ -243,7 +261,10 @@ struct PracticeSessionService {
|
|||||||
private func pickCommonTenseForm() -> VerbForm? {
|
private func pickCommonTenseForm() -> VerbForm? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let coreTenseIDs = TenseID.coreTenseIDs
|
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 }
|
guard let verb = verbs.randomElement() else { return nil }
|
||||||
|
|
||||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||||
@@ -256,7 +277,10 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
private func pickRandomForm() -> VerbForm? {
|
private func pickRandomForm() -> VerbForm? {
|
||||||
let settings = settings()
|
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 }
|
guard let verb = verbs.randomElement() else { return nil }
|
||||||
|
|
||||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||||
|
|||||||
@@ -27,6 +27,50 @@ struct ReferenceStore {
|
|||||||
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
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? {
|
func fetchVerb(id: Int) -> Verb? {
|
||||||
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
||||||
return (try? context.fetch(descriptor))?.first
|
return (try? context.fetch(descriptor))?.first
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
private func completeOnboarding() {
|
private func completeOnboarding() {
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
progress.selectedVerbLevel = selectedLevel
|
progress.selectedVerbLevels = [selectedLevel]
|
||||||
if progress.enabledTenseIDs.isEmpty {
|
if progress.enabledTenseIDs.isEmpty {
|
||||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import SharedModels
|
|||||||
struct LyricsReaderView: View {
|
struct LyricsReaderView: View {
|
||||||
let song: SavedSong
|
let song: SavedSong
|
||||||
|
|
||||||
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
|
@State private var selectedWord: LyricsWordLookup?
|
||||||
|
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -15,6 +19,10 @@ struct LyricsReaderView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(song.title)
|
.navigationTitle(song.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedWord) { word in
|
||||||
|
LyricsWordDetailSheet(word: word)
|
||||||
|
.presentationDetents([.height(260)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
@@ -56,15 +64,6 @@ struct LyricsReaderView: View {
|
|||||||
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||||
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||||
let lineCount = max(spanishLines.count, englishLines.count)
|
let lineCount = max(spanishLines.count, englishLines.count)
|
||||||
let _ = {
|
|
||||||
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
|
|
||||||
for i in 0..<min(15, lineCount) {
|
|
||||||
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
|
|
||||||
let en = i < englishLines.count ? englishLines[i] : "(none)"
|
|
||||||
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
|
|
||||||
print(" EN: \(en.isEmpty ? "(blank)" : en)")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 0) {
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<lineCount, id: \.self) { index in
|
ForEach(0..<lineCount, id: \.self) { index in
|
||||||
@@ -78,8 +77,7 @@ struct LyricsReaderView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
if !es.isEmpty {
|
if !es.isEmpty {
|
||||||
Text(es)
|
spanishLine(es)
|
||||||
.font(.body.weight(.medium))
|
|
||||||
}
|
}
|
||||||
if !en.isEmpty {
|
if !en.isEmpty {
|
||||||
Text(en)
|
Text(en)
|
||||||
@@ -94,4 +92,183 @@ struct LyricsReaderView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func spanishLine(_ line: String) -> some View {
|
||||||
|
let tokens = line.components(separatedBy: " ")
|
||||||
|
return LyricsFlowLayout(spacing: 0) {
|
||||||
|
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||||
|
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||||
|
selectedWord = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lookup
|
||||||
|
|
||||||
|
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
|
||||||
|
let cleaned = rawToken.lowercased()
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !cleaned.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if let cached = lookupCache[cleaned] { return cached }
|
||||||
|
guard let entry = dictionary.lookup(cleaned) else { return nil }
|
||||||
|
|
||||||
|
let displayWord = rawToken
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
|
||||||
|
|
||||||
|
let lookup = LyricsWordLookup(
|
||||||
|
word: displayWord.isEmpty ? entry.word : displayWord,
|
||||||
|
baseForm: entry.baseForm,
|
||||||
|
english: entry.english,
|
||||||
|
partOfSpeech: entry.partOfSpeech,
|
||||||
|
tenseDisplay: tenseDisplay,
|
||||||
|
person: entry.person
|
||||||
|
)
|
||||||
|
lookupCache[cleaned] = lookup
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word Lookup Model
|
||||||
|
|
||||||
|
private struct LyricsWordLookup: Identifiable, Hashable {
|
||||||
|
let word: String
|
||||||
|
let baseForm: String
|
||||||
|
let english: String
|
||||||
|
let partOfSpeech: String
|
||||||
|
let tenseDisplay: String?
|
||||||
|
let person: String?
|
||||||
|
|
||||||
|
var id: String { word }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word View
|
||||||
|
|
||||||
|
private struct LyricsWordView: View {
|
||||||
|
let token: String
|
||||||
|
let lookup: LyricsWordLookup?
|
||||||
|
let onLookup: (LyricsWordLookup) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(token + " ")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.underline(lookup != nil, color: .teal.opacity(0.35))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onLongPressGesture(minimumDuration: 0.35) {
|
||||||
|
if let lookup {
|
||||||
|
onLookup(lookup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail Sheet
|
||||||
|
|
||||||
|
private struct LyricsWordDetailSheet: View {
|
||||||
|
let word: LyricsWordLookup
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text(word.word)
|
||||||
|
.font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
if !word.partOfSpeech.isEmpty {
|
||||||
|
Text(word.partOfSpeech)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
|
||||||
|
detailRow(label: "Base form", value: word.baseForm, italic: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !word.english.isEmpty {
|
||||||
|
detailRow(label: "English", value: word.english)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tenseDisplay = word.tenseDisplay {
|
||||||
|
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
|
||||||
|
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text("\(label):")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 86, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.italic(italic)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow Layout
|
||||||
|
|
||||||
|
private struct LyricsFlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 0
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var height: CGFloat = 0
|
||||||
|
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||||
|
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||||
|
return CGSize(width: proposal.width ?? 0, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var y = bounds.minY
|
||||||
|
var idx = 0
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX
|
||||||
|
let rh = row.map { $0.height }.max() ?? 0
|
||||||
|
for size in row {
|
||||||
|
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
y += rh + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||||
|
let mw = proposal.width ?? .infinity
|
||||||
|
var rows: [[CGSize]] = [[]]
|
||||||
|
var cw: CGFloat = 0
|
||||||
|
for sv in subviews {
|
||||||
|
let s = sv.sizeThatFits(.unspecified)
|
||||||
|
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
|
||||||
|
rows.append([])
|
||||||
|
cw = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1].append(s)
|
||||||
|
cw += s.width
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ struct SettingsView: View {
|
|||||||
@State private var dailyGoal: Double = 50
|
@State private var dailyGoal: Double = 50
|
||||||
@State private var showVosotros: Bool = true
|
@State private var showVosotros: Bool = true
|
||||||
@State private var autoFillStem: Bool = false
|
@State private var autoFillStem: Bool = false
|
||||||
@State private var selectedLevel: VerbLevel = .basic
|
|
||||||
|
|
||||||
private let levels = VerbLevel.allCases
|
private let levels = VerbLevel.allCases
|
||||||
|
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||||
|
.spelling, .stemChange, .uniqueIrregular
|
||||||
|
]
|
||||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -40,19 +42,26 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Level") {
|
Section {
|
||||||
Picker("Current Level", selection: $selectedLevel) {
|
|
||||||
ForEach(levels, id: \.self) { level in
|
ForEach(levels, id: \.self) { level in
|
||||||
Text(level.displayName).tag(level)
|
Toggle(level.displayName, isOn: Binding(
|
||||||
}
|
get: {
|
||||||
}
|
progress?.selectedVerbLevels.contains(level) ?? false
|
||||||
.onChange(of: selectedLevel) { _, newValue in
|
},
|
||||||
progress?.selectedVerbLevel = newValue
|
set: { enabled in
|
||||||
|
guard let progress else { return }
|
||||||
|
progress.setLevelEnabled(level, enabled: enabled)
|
||||||
saveProgress()
|
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
|
ForEach(TenseInfo.all) { tense in
|
||||||
Toggle(tense.english, isOn: Binding(
|
Toggle(tense.english, isOn: Binding(
|
||||||
get: {
|
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") {
|
Section("Stats") {
|
||||||
@@ -96,7 +126,6 @@ struct SettingsView: View {
|
|||||||
dailyGoal = Double(resolved.dailyGoal)
|
dailyGoal = Double(resolved.dailyGoal)
|
||||||
showVosotros = resolved.showVosotros
|
showVosotros = resolved.showVosotros
|
||||||
autoFillStem = resolved.autoFillStem
|
autoFillStem = resolved.autoFillStem
|
||||||
selectedLevel = resolved.selectedVerbLevel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveProgress() {
|
private func saveProgress() {
|
||||||
|
|||||||
@@ -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