diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index d475db0e..4320bdbb 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; + A62300000000000000000004 /* DictionaryTrainingStepModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; @@ -56,6 +57,7 @@ A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = ""; }; + A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryTrainingStepModelTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -135,6 +137,7 @@ A62300000000000000000001 /* AudioBufferConverterTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, + A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -295,6 +298,7 @@ A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */, + A62300000000000000000004 /* DictionaryTrainingStepModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index d03d4957..0f5378e7 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -47,6 +47,10 @@ struct CustomDictionaryView: View { @State private var isAutomaticTrainingEnabled = false @State private var isTrainedReplacementButtonHovered = false @State private var isTrainedReplacementGlowExpanded = false + @State private var hasReachedVerifyStep = false + @State private var manualExpandedTrainingStep: DictionaryTrainingStep? + @State private var lastAnnouncedTrainingStep: DictionaryTrainingStep = .word + @FocusState private var isTrainingWordFieldFocused: Bool @State private var replacementConfirmation: ReplacementConfirmation? @State private var composerMode: DictionaryComposerMode = .train @State private var manualTriggerDraft = "" @@ -76,12 +80,8 @@ struct CustomDictionaryView: View { ) } - private var trainingTargetReference: String { - DictionaryTrainingCopy.target(for: self.normalizedTrainingReplacement) - } - private var composerModeDetail: String { - DictionaryTrainingCopy.composerDetail(mode: self.composerMode, target: self.trainingTargetReference) + self.composerMode.detail } private var canUseTrainingRecorderButton: Bool { @@ -93,7 +93,7 @@ struct CustomDictionaryView: View { } private var trainingRecorderIsStop: Bool { - self.isAutomaticTrainingEnabled || self.isTrainingRecording || self.isTrainingStarting + self.isTrainingRecordingLocked } private var trainingRecorderButtonTitle: String { @@ -103,30 +103,6 @@ struct CustomDictionaryView: View { return self.canRetryTrainingAfterMaximum ? "Try Again" : "Start" } - private var trainingFinalOutputIsReady: Bool { - if self.activePronunciationMatching { - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingPronunciationEnrollments.count >= CustomDictionaryTrainingMerge.readyCoveredCount - } - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingOutputIsCovered && - self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount - } - - private var trainingAlreadyCorrectWithoutReplacement: Bool { - if self.activePronunciationMatching { - return self.trainingVariants.isEmpty && - !self.lastTrainingOutput.isEmpty && - self.lastTrainingOutput.caseInsensitiveCompare(self.normalizedTrainingReplacement) == .orderedSame && - self.trainingPronunciationEnrollments.count >= CustomDictionaryTrainingMerge.readyCoveredCount - } - return self.trainingVariants.isEmpty && - self.trainingOutputIsCovered && - !self.lastTrainingOutput.isEmpty && - self.lastTrainingOutput.caseInsensitiveCompare(self.normalizedTrainingReplacement) == .orderedSame && - self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount - } - private var trainingReadinessProgress: Int { if self.activePronunciationMatching { return min(self.trainingPronunciationEnrollments.count, CustomDictionaryTrainingMerge.readyCoveredCount) @@ -139,12 +115,15 @@ struct CustomDictionaryView: View { } private var trainingOutputIsCovered: Bool { - if self.activePronunciationMatching { - return !self.trainingPronunciationEnrollments.isEmpty - } - return self.lastTrainingOutputIsCovered + DictionaryTrainingStepModel.isOutputCovered( + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + activePronunciationMatching: self.activePronunciationMatching + ) } + // MARK: - Train by Voice accordion + private var trainingFinalOutputText: String { guard !self.lastTrainingOutput.isEmpty else { return "Record to check" } return self.trainingOutputIsCovered ? self.normalizedTrainingReplacement : self.lastTrainingOutput @@ -480,76 +459,6 @@ struct CustomDictionaryView: View { ) } - private var trainReplacementComposer: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) - .dictionaryInputChrome() - .disabled(self.isTrainingRecording || self.isTrainingProcessing) - .onChange(of: self.trainingReplacement) { oldValue, newValue in - self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) - } - - self.voiceMatchingSettingsRow - - self.trainingRecorderPanel - - self.trainingFinalOutputPanel - - if !self.trainingVariants.isEmpty { - self.trainingHeardSection - } - - self.trainingFooter - - Spacer(minLength: 0) - - Button { - Task { await self.addTrainedReplacement() } - } label: { - Label( - self.trainedReplacementButtonTitle, - systemImage: self.shouldEmphasizeTrainedReplacementButton - ? "sparkles" - : (self.trainingAlreadyCorrectWithoutReplacement ? "checkmark" : "plus") - ) - .frame(maxWidth: .infinity) - .frame(height: 38) - } - .fluidButton(self.shouldEmphasizeTrainedReplacementButton ? .accent : .compact, size: .small) - .disabled(!self.canAddTrainedReplacement) - .opacity(self.canAddTrainedReplacement ? 1 : 0.62) - .overlay(self.trainedReplacementButtonReadyOutline) - .shadow( - color: self.shouldEmphasizeTrainedReplacementButton - ? self.theme.palette.accent.opacity(self.isTrainedReplacementGlowExpanded ? 0.34 : 0.14) - : .clear, - radius: self.shouldEmphasizeTrainedReplacementButton - ? (self.isTrainedReplacementGlowExpanded ? 18 : 8) - : 0, - x: 0, - y: 4 - ) - .onHover { self.isTrainedReplacementButtonHovered = $0 } - .onAppear { self.updateTrainedReplacementGlow() } - .onChange(of: self.shouldPulseTrainedReplacementButton) { _, _ in - self.updateTrainedReplacementGlow() - } - } - .task { - await DictionaryTrainingEndpointMonitor.shared.prepare() - } - } - - private var trainedReplacementButtonReadyOutline: some View { - RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) - .stroke( - self.shouldEmphasizeTrainedReplacementButton ? self.theme.palette.success.opacity(0.72) : .clear, - lineWidth: 1.5 - ) - .padding(-3) - .allowsHitTesting(false) - } - private var manualReplacementComposer: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { ViewThatFits(in: .horizontal) { @@ -640,41 +549,18 @@ struct CustomDictionaryView: View { private var trainingRecorderPanel: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { - Text("Teach FluidVoice your pronunciation") - .font(self.theme.typography.bodySmallStrong) - - if self.trainingAlreadyCorrectWithoutReplacement { - Label("\(self.trainingTargetReference) is already recognized correctly.", systemImage: "checkmark.circle.fill") - .font(self.theme.typography.captionStrong) - .foregroundStyle(self.theme.palette.accent) - } else if self.trainingFinalOutputIsReady { - Label( - self.activePronunciationMatching - ? "Voice profile for \(self.trainingTargetReference) captured 3 times." - : "FluidVoice recognized \(self.trainingTargetReference) 3 times in a row.", - systemImage: "checkmark.circle.fill" - ) - .font(self.theme.typography.captionStrong) - .foregroundStyle(self.theme.palette.accent) - } else { + // Once ready or already correct, the ring and its caption carry the outcome. + if !self.trainingAlreadyCorrectWithoutReplacement, !self.trainingFinalOutputIsReady { VStack(alignment: .leading, spacing: 7) { self.trainingInstruction( number: 1, - text: "Type the correct word you want to teach in the box above." + text: "Press Start, say the word naturally, then pause." ) self.trainingInstruction( number: 2, - text: "Press Start once." - ) - self.trainingInstruction( - number: 3, - text: "Say \(self.trainingTargetReference) naturally, then pause. FluidVoice records and listens again automatically." - ) - self.trainingInstruction( - number: 4, text: self.activePronunciationMatching - ? "Repeat 3 times to teach FluidVoice how your voice sounds." - : "Keep repeating it until the circle reaches 3/3." + ? "Repeat 3 times so FluidVoice learns how your voice sounds." + : "Repeat until the ring reaches 3/3." ) } } @@ -728,7 +614,6 @@ struct CustomDictionaryView: View { private var trainingReadinessCaption: String { DictionaryTrainingCopy.readinessCaption( - target: self.trainingTargetReference, isAlreadyCorrect: self.trainingAlreadyCorrectWithoutReplacement, isReady: self.trainingFinalOutputIsReady, usesVoiceMatching: self.activePronunciationMatching @@ -816,12 +701,6 @@ struct CustomDictionaryView: View { private var trainingFooter: some View { if self.trainingHasError || self.isTrainingActive || !self.trainingVariants.isEmpty { HStack(spacing: self.theme.metrics.spacing.sm) { - if self.trainingHasError { - Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.warning) - } - if self.isTrainingActive || !self.trainingVariants.isEmpty || !self.normalizedTrainingReplacement.isEmpty { Spacer() @@ -1870,6 +1749,11 @@ struct CustomDictionaryView: View { await self.startTrainingSample() } + private func resetTrainingStepLatches() { + self.hasReachedVerifyStep = false + self.lastAnnouncedTrainingStep = .word + } + private func resetTrainingVerificationAttempts() { self.trainingSampleCount = 0 self.lastTrainingOutput = "" @@ -1877,6 +1761,8 @@ struct CustomDictionaryView: View { self.consecutiveCoveredCaptures = 0 self.trainingStatusMessage = "" self.trainingHasError = false + // Drop both latches, or Try Again leaves the accordion stuck on step ③. + self.resetTrainingStepLatches() } private func addTrainingVariant(from transcript: String) { @@ -1905,7 +1791,7 @@ struct CustomDictionaryView: View { self.trainingHasError = false if self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount { self.trainingStatusMessage = self.trainingVariants.isEmpty - ? "Looks good already. No replacement needed." + ? DictionaryTrainingCopy.alreadyCorrectCaption : "Looks ready. Add this replacement when you're ready." } else { self.trainingStatusMessage = "Covered. Try a couple more." @@ -1995,6 +1881,8 @@ struct CustomDictionaryView: View { private func removeTrainingVariant(_ variant: String) { self.trainingVariants.removeAll { $0 == variant } self.refreshLastTrainingCoverage() + // May drop below ready; clear the latches so the step re-derives from coverage. + self.resetTrainingStepLatches() } private func refreshLastTrainingCoverage() { @@ -2034,6 +1922,8 @@ struct CustomDictionaryView: View { self.isTrainingRecording = false self.trainingStopRequestedDuringStart = false self.isTrainingProcessing = false + self.resetTrainingStepLatches() + self.manualExpandedTrainingStep = nil } private func handleTrainingReplacementChange(oldValue: String, newValue: String) { @@ -2048,6 +1938,7 @@ struct CustomDictionaryView: View { self.lastTrainingOutputIsCovered = false self.consecutiveCoveredCaptures = 0 self.isTrainingActive = false + self.resetTrainingStepLatches() if newKey.isEmpty { self.trainingStatusMessage = "Type the correct text." } else if self.trainingVariants.isEmpty { @@ -2256,7 +2147,9 @@ struct CustomDictionaryView: View { } private extension CustomDictionaryView { - var asr: ASRService { self.appServices.asr } + var asr: ASRService { + self.appServices.asr + } var trainedReplacementButtonTitle: String { self.trainingAlreadyCorrectWithoutReplacement ? "Nothing to Save" : "Add Replacement" @@ -2285,13 +2178,316 @@ private extension CustomDictionaryView { DictionaryTrainingEndpointMonitor.shared.stop() self.trainingVariants = self.existingTrainingVariants(for: self.trainingReplacement) self.trainingPronunciationEnrollments = [] - self.resetTrainingVerificationAttempts() + self.resetTrainingVerificationAttempts() // also clears hasReachedVerifyStep + // Progress is reset, so a stale manual override would pin the wrong panel. + self.manualExpandedTrainingStep = nil self.trainingStatusMessage = self.normalizedTrainingReplacement.isEmpty ? "Type the correct text." : "" } } +// MARK: - Train by Voice accordion + +/// Split out to keep type_body_length in check; behavior is unchanged. +private extension CustomDictionaryView { + var trainingSnapshot: DictionaryTrainingSnapshot { + DictionaryTrainingSnapshot( + normalizedWord: self.normalizedTrainingReplacement, + consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + lastTrainingOutput: self.lastTrainingOutput, + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: self.trainingVariants.isEmpty, + activePronunciationMatching: self.activePronunciationMatching + ) + } + + var trainingFinalOutputIsReady: Bool { + DictionaryTrainingStepModel.finalOutputIsReady( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) + } + + var trainingAlreadyCorrectWithoutReplacement: Bool { + DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) + } + + var isTrainingRecordingLocked: Bool { + self.isTrainingRecording || self.isTrainingStarting || self.isAutomaticTrainingEnabled + } + + var isTrainingVerifyReady: Bool { + self.trainingFinalOutputIsReady || self.trainingAlreadyCorrectWithoutReplacement + } + + var derivedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.derivedStep( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount, + hasReachedVerify: self.hasReachedVerifyStep + ) + } + + var expandedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.resolveExpandedStep( + derived: self.derivedTrainingStep, + manualOverride: self.manualExpandedTrainingStep, + isRecordingLocked: self.isTrainingRecordingLocked, + isWordFieldFocused: self.isTrainingWordFieldFocused + ) + } + + /// True when step ① is manually reopened after progress exists. + var isReopeningTrainingWordStepAfterProgress: Bool { + self.manualExpandedTrainingStep == .word && + self.derivedTrainingStep != .word && + (self.trainingSampleCount > 0 || !self.trainingPronunciationEnrollments.isEmpty) + } + + func isTrainingStepInteractive(_ step: DictionaryTrainingStep) -> Bool { + DictionaryTrainingStepModel.isStepInteractive( + step, + derived: self.derivedTrainingStep, + isRecordingLocked: self.isTrainingRecordingLocked, + wordIsEmpty: self.normalizedTrainingReplacement.isEmpty + ) + } + + func selectTrainingStep(_ step: DictionaryTrainingStep) { + guard self.isTrainingStepInteractive(step) else { return } + self.manualExpandedTrainingStep = step + if step == .word { + // Programmatic focus to a not-yet-rendered field is unreliable on macOS. + Task { @MainActor in + self.isTrainingWordFieldFocused = true + } + } else { + self.isTrainingWordFieldFocused = false + } + } + + var trainReplacementComposer: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.trainingStepHeader(.word) + if self.expandedTrainingStep == .word { + self.trainingWordStepBody + } + + self.trainingStepHeader(.record) + if self.expandedTrainingStep == .record { + self.trainingRecordStepBody + } + + self.trainingStepHeader(.verify) + if self.expandedTrainingStep == .verify { + self.trainingVerifyStepBody + } + + // Outside the step bodies so failures surface whichever step is expanded. + if self.trainingHasError { + Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + } + .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.expandedTrainingStep) + .task { + await DictionaryTrainingEndpointMonitor.shared.prepare() + } + // On the accordion, not the TextField: step ① may be collapsed when the word changes. + .onChange(of: self.trainingReplacement) { oldValue, newValue in + self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) + } + .onChange(of: self.derivedTrainingStep) { _, _ in + self.manualExpandedTrainingStep = nil + } + .onChange(of: self.expandedTrainingStep) { oldStep, newStep in + self.announceTrainingStepEdgeIfNeeded(from: oldStep, to: newStep) + } + .onChange(of: self.isTrainingRecordingLocked) { _, isLocked in + if isLocked { + self.manualExpandedTrainingStep = nil + } + } + .onChange(of: self.isTrainingVerifyReady) { _, isReady in + if isReady { + self.hasReachedVerifyStep = true + } + } + } + + func announceTrainingStepEdgeIfNeeded(from oldStep: DictionaryTrainingStep, to newStep: DictionaryTrainingStep) { + guard oldStep != newStep else { return } + // Lower the latch on a backward edge so a later re-advance announces again. + if newStep.rawValue < self.lastAnnouncedTrainingStep.rawValue { + self.lastAnnouncedTrainingStep = newStep + } + // Includes the word→verify jump; the same-step guard prevents double announces. + guard newStep.rawValue > oldStep.rawValue, self.lastAnnouncedTrainingStep != newStep else { return } + self.lastAnnouncedTrainingStep = newStep + AccessibilityNotification.Announcement(DictionaryTrainingCopy.stepAnnouncement(for: newStep)).post() + } + + @ViewBuilder + func trainingStepHeader(_ step: DictionaryTrainingStep) -> some View { + let isInteractive = self.isTrainingStepInteractive(step) + DictionaryTrainingStepHeaderView( + step: step, + status: self.trainingStepStatus(step), + title: DictionaryTrainingCopy.stepTitle(step), + isExpanded: self.expandedTrainingStep == step, + isInteractive: isInteractive + ) { + self.selectTrainingStep(step) + } + } + + func trainingStepStatus(_ step: DictionaryTrainingStep) -> DictionaryTrainingStepHeaderView.Status { + if step.rawValue < self.derivedTrainingStep.rawValue { + // Latched Verify doesn't mean Record is complete — captures are no longer sufficient. + if step == .record, self.derivedTrainingStep == .verify, !self.isTrainingVerifyReady { + return .current + } + return .complete + } + if step == self.derivedTrainingStep { + return .current + } + return .upcoming + } + + var trainingWordStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) + .dictionaryInputChrome() + .disabled(self.isTrainingRecording || self.isTrainingProcessing) + .focused(self.$isTrainingWordFieldFocused) + .onSubmit { + self.advanceFromWordStep() + } + + if self.isReopeningTrainingWordStepAfterProgress { + Label(DictionaryTrainingCopy.editingWordRestartsTrainingCaption, systemImage: "exclamationmark.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + /// Commits the word and advances past step ①. No-op while empty, so Tab/Return + /// can't strand the user on an empty Record step. + func advanceFromWordStep() { + guard !self.normalizedTrainingReplacement.isEmpty else { return } + self.manualExpandedTrainingStep = nil + self.isTrainingWordFieldFocused = false + } + + var trainingRecordStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.voiceMatchingSettingsRow + + self.trainingRecorderPanel + + if let caption = self.trainingStartDisabledCaption { + Label(caption, systemImage: "info.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) + } + + if !self.trainingVariants.isEmpty { + self.trainingHeardSection + } + + self.trainingFooter + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + /// Copy for the three Start-disabled causes in step ②. Word-empty is excluded: + /// the Record header is inert without a word. + var trainingStartDisabledCaption: String? { + if self.asr.isRunning, !self.isTrainingRecording, !self.isTrainingStarting, !self.isAutomaticTrainingEnabled { + return DictionaryTrainingCopy.dictationRunningCaption + } + if self.isTrainingProcessing { + return DictionaryTrainingCopy.trainingProcessingCaption + } + if self.trainingSampleCount >= CustomDictionaryTrainingMerge.maxSamples { + return DictionaryTrainingCopy.maxSamplesReachedCaption + } + return nil + } + + var trainingVerifyStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + if !self.trainingVariants.isEmpty { + self.trainingHeardSection + } + + self.trainingFinalOutputPanel + + if self.trainingAlreadyCorrectWithoutReplacement { + Label(DictionaryTrainingCopy.alreadyCorrectCaption, systemImage: "checkmark.circle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.accent) + } + + Button { + Task { await self.addTrainedReplacement() } + } label: { + Label( + self.trainedReplacementButtonTitle, + systemImage: self.shouldEmphasizeTrainedReplacementButton + ? "sparkles" + : (self.trainingAlreadyCorrectWithoutReplacement ? "checkmark" : "plus") + ) + .frame(maxWidth: .infinity) + .frame(height: 38) + } + .fluidButton(self.shouldEmphasizeTrainedReplacementButton ? .accent : .compact, size: .small) + .disabled(!self.canAddTrainedReplacement) + .opacity(self.canAddTrainedReplacement ? 1 : 0.62) + .overlay(self.trainedReplacementButtonReadyOutline) + .shadow( + color: self.shouldEmphasizeTrainedReplacementButton + ? self.theme.palette.accent.opacity(self.isTrainedReplacementGlowExpanded ? 0.34 : 0.14) + : .clear, + radius: self.shouldEmphasizeTrainedReplacementButton + ? (self.isTrainedReplacementGlowExpanded ? 18 : 8) + : 0, + x: 0, + y: 4 + ) + .onHover { self.isTrainedReplacementButtonHovered = $0 } + .onAppear { self.updateTrainedReplacementGlow() } + .onChange(of: self.shouldPulseTrainedReplacementButton) { _, _ in + self.updateTrainedReplacementGlow() + } + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + var trainingStepBodyLeadingInset: CGFloat { + 28 + } + + var trainedReplacementButtonReadyOutline: some View { + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke( + self.shouldEmphasizeTrainedReplacementButton ? self.theme.palette.success.opacity(0.72) : .clear, + lineWidth: 1.5 + ) + .padding(-3) + .allowsHitTesting(false) + } +} + private struct VoiceMatchingSettingsRow: View { @Binding var isEnabled: Bool @@ -2368,9 +2564,11 @@ private struct VoiceMatchingSettingsRow: View { .fill( isSelected ? self.theme.palette.accent - : (isHovered - ? self.theme.palette.accent.opacity(0.1) - : self.theme.palette.cardBackground.opacity(0.5)) + : ( + isHovered + ? self.theme.palette.accent.opacity(0.1) + : self.theme.palette.cardBackground.opacity(0.5) + ) ) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) @@ -2510,39 +2708,58 @@ private struct DictionaryFocusDismissMonitor: NSViewRepresentable { } private enum DictionaryTrainingCopy { - static func target(for normalizedTarget: String) -> String { - normalizedTarget.isEmpty ? "the word" : "“\(normalizedTarget)”" - } - - static func composerDetail(mode: DictionaryComposerMode, target: String) -> String { - mode == .train && target != "the word" ? "Teach \(target) by speaking it." : mode.detail - } - + /// The word is already on screen in step ①, so readiness copy doesn't echo it. static func readinessCaption( - target: String, isAlreadyCorrect: Bool, isReady: Bool, usesVoiceMatching: Bool ) -> String { if isAlreadyCorrect { - return "No replacement is needed for \(target)." + return "No replacement needed." } if isReady { return usesVoiceMatching - ? "Ready. FluidVoice learned how \(target) sounds in your voice." - : "Ready. FluidVoice got \(target) right 3 times in a row." + ? "Ready. FluidVoice learned how you say it." + : "Ready. Recognized 3 times in a row." } return usesVoiceMatching - ? "Say \(target) 3 times to unlock Add Replacement." - : "Keep trying until FluidVoice gets \(target) right 3 times in a row." + ? "Say it 3 times to unlock Add Replacement." + : "Keep going until it is recognized 3 times in a row." } + + // MARK: - Train by Voice accordion + + /// Titles carry the instruction, so steps need no subtitle. + static func stepTitle(_ step: DictionaryTrainingStep) -> String { + switch step { + case .word: return "Type the word to teach" + case .record: return "Say it 3 times" + case .verify: return "Verify & save" + } + } + + static func stepAnnouncement(for step: DictionaryTrainingStep) -> String { + switch step { + case .word: return "Step 1, Word." + case .record: return "Step 2, Record." + case .verify: return "Step 3, Verify and Save." + } + } + + static let editingWordRestartsTrainingCaption = "Editing the word restarts voice training." + static let dictationRunningCaption = "Dictation is running — stop dictating to train." + static let trainingProcessingCaption = "Processing…" + static let maxSamplesReachedCaption = "Max samples reached — press Try Again or Clear." + static let alreadyCorrectCaption = "Looks good already. No replacement needed." } private enum DictionaryComposerMode: CaseIterable, Identifiable { case train case manual - var id: Self { self } + var id: Self { + self + } var title: String { switch self { @@ -2621,9 +2838,11 @@ private struct DictionaryComposerModeTab: View { .fill( self.isSelected ? self.theme.palette.accent - : (self.isHovered - ? self.theme.palette.accent.opacity(0.1) - : self.theme.palette.cardBackground.opacity(0.5)) + : ( + self.isHovered + ? self.theme.palette.accent.opacity(0.1) + : self.theme.palette.cardBackground.opacity(0.5) + ) ) .overlay( RoundedRectangle(cornerRadius: self.theme.metrics.corners.sm, style: .continuous) @@ -2637,6 +2856,106 @@ private struct DictionaryComposerModeTab: View { } } +private struct DictionaryTrainingStepHeaderView: View { + enum Status { + case upcoming + case current + case complete + } + + let step: DictionaryTrainingStep + let status: Status + let title: String + let isExpanded: Bool + let isInteractive: Bool + let action: () -> Void + + @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isHovered = false + + var body: some View { + Button(action: self.action) { + HStack(alignment: .center, spacing: self.theme.metrics.spacing.md) { + self.statusGlyph + + Text("\(self.step.rawValue + 1). \(self.title)") + .font(self.theme.typography.bodySmallStrong) + .foregroundStyle(self.theme.palette.primaryText) + .lineLimit(1) + + Spacer(minLength: self.theme.metrics.spacing.sm) + + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(self.theme.palette.tertiaryText) + .rotationEffect(.degrees(self.isExpanded ? 90 : 0)) + } + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.vertical, self.theme.metrics.spacing.sm) + .background( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .fill( + self.isExpanded + ? self.theme.palette.contentBackground.opacity(0.55) + : ( + self.isHovered + ? self.theme.palette.contentBackground.opacity(0.32) + : Color.clear + ) + ) + .overlay( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(self.isExpanded ? 0.28 : 0), lineWidth: 1) + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!self.isInteractive) + .opacity(self.isInteractive ? 1 : 0.55) + .onHover { hovering in + guard self.isInteractive else { return } + guard !self.reduceMotion else { + self.isHovered = hovering + return + } + withAnimation(.easeOut(duration: 0.14)) { + self.isHovered = hovering + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Step \(self.step.rawValue + 1), \(self.title), \(self.statusAccessibilityDescription)") + .accessibilityAddTraits(self.isExpanded ? .isSelected : []) + } + + private var statusAccessibilityDescription: String { + switch self.status { + case .upcoming: return "not started" + case .current: return "in progress" + case .complete: return "complete" + } + } + + @ViewBuilder + private var statusGlyph: some View { + switch self.status { + case .upcoming: + Circle() + .stroke(self.theme.palette.cardBorder.opacity(0.6), lineWidth: 1.5) + .frame(width: 18, height: 18) + case .current: + Circle() + .fill(self.theme.palette.accent) + .frame(width: 18, height: 18) + case .complete: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(self.theme.palette.success) + } + } +} + enum CustomDictionaryManualEntry { static func normalizedTrigger(_ text: String) -> String? { let trigger = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -2961,7 +3280,9 @@ private enum BoostStrengthPreset: String, CaseIterable, Identifiable { case balanced = "Balanced" case strong = "Strong" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var weight: Float { switch self { diff --git a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift new file mode 100644 index 00000000..16a90e98 --- /dev/null +++ b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The three always-visible steps of the "Train by Voice" accordion composer. +enum DictionaryTrainingStep: Int, CaseIterable, Equatable { + case word + case record + case verify +} + +/// Immutable snapshot of the primitive training state the step model derives from. +struct DictionaryTrainingSnapshot: Equatable { + let normalizedWord: String + let consecutiveCoveredCaptures: Int + let pronunciationEnrollmentCount: Int + let lastTrainingOutput: String + let lastTrainingOutputIsCovered: Bool + let trainingVariantsIsEmpty: Bool + let activePronunciationMatching: Bool +} + +enum DictionaryTrainingStepModel { + static func isOutputCovered( + lastTrainingOutputIsCovered: Bool, + pronunciationEnrollmentCount: Int, + activePronunciationMatching: Bool + ) -> Bool { + if activePronunciationMatching { + return pronunciationEnrollmentCount > 0 + } + return lastTrainingOutputIsCovered + } + + static func alreadyCorrectWithoutReplacement( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int + ) -> Bool { + if snapshot.activePronunciationMatching { + return snapshot.trainingVariantsIsEmpty && + !snapshot.lastTrainingOutput.isEmpty && + snapshot.lastTrainingOutput.caseInsensitiveCompare(snapshot.normalizedWord) == .orderedSame && + snapshot.pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return snapshot.trainingVariantsIsEmpty && + outputIsCovered && + !snapshot.lastTrainingOutput.isEmpty && + snapshot.lastTrainingOutput.caseInsensitiveCompare(snapshot.normalizedWord) == .orderedSame && + snapshot.consecutiveCoveredCaptures >= readyCoveredCount + } + + static func finalOutputIsReady( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int + ) -> Bool { + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + snapshot, + readyCoveredCount: readyCoveredCount + ) + + if snapshot.activePronunciationMatching { + return !alreadyCorrect && snapshot.pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return !alreadyCorrect && outputIsCovered && snapshot.consecutiveCoveredCaptures >= readyCoveredCount + } + + /// Derives the step from primitive state: `.word` when empty, `.verify` when ready, + /// already-correct, or latched (a post-ready miss must not snap back), else `.record`. + static func derivedStep( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int, + hasReachedVerify: Bool + ) -> DictionaryTrainingStep { + guard !snapshot.normalizedWord.isEmpty else { return .word } + + let ready = self.finalOutputIsReady( + snapshot, + readyCoveredCount: readyCoveredCount + ) + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + snapshot, + readyCoveredCount: readyCoveredCount + ) + + if ready || alreadyCorrect || hasReachedVerify { + return .verify + } + return .record + } + + /// Resolves the expanded step. Priority: recording lock, word-field focus, manual tap, + /// then the derived step. + static func resolveExpandedStep( + derived: DictionaryTrainingStep, + manualOverride: DictionaryTrainingStep?, + isRecordingLocked: Bool, + isWordFieldFocused: Bool + ) -> DictionaryTrainingStep { + if isRecordingLocked { + return .record + } + if isWordFieldFocused { + return .word + } + if let manualOverride { + return manualOverride + } + return derived + } + + /// Whether a header can be tapped. The lock pins `.record`; `.record`/`.verify` need a + /// word; `.verify` waits for the derived step, or it opens with Save disabled. + static func isStepInteractive( + _ step: DictionaryTrainingStep, + derived: DictionaryTrainingStep, + isRecordingLocked: Bool, + wordIsEmpty: Bool + ) -> Bool { + if isRecordingLocked { return step == .record } + if step != .word, wordIsEmpty { return false } + if step == .verify, derived != .verify { return false } + return true + } +} diff --git a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift new file mode 100644 index 00000000..3ca88002 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift @@ -0,0 +1,446 @@ +@testable import FluidVoice_Debug +import XCTest + +final class DictionaryTrainingStepModelTests: XCTestCase { + /// Pinned to the production constant so a threshold change fails these tests. + private var readyCoveredCount: Int { + CustomDictionaryTrainingMerge.readyCoveredCount + } + + private func derived( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false, + hasReachedVerify: Bool = false + ) -> DictionaryTrainingStep { + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ) + return DictionaryTrainingStepModel.derivedStep( + snapshot, + readyCoveredCount: self.readyCoveredCount, + hasReachedVerify: hasReachedVerify + ) + } + + // MARK: - derivedStep + + func testEmptyWordDerivesWordStep() { + // Caller pre-trims, so only "" counts as empty. + XCTAssertEqual(self.derived(word: ""), .word) + } + + func testNonEmptyWordWithNoProgressDerivesRecordStep() { + XCTAssertEqual(self.derived(word: "FluidVoice"), .record) + } + + func testReadyAfterThreeConsecutiveCoveredCapturesDerivesVerifyStep() { + let step = self.derived( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .verify) + } + + func testAlmostReadyStaysOnRecordStep() { + let step = self.derived( + consecutiveCoveredCaptures: 2, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .record) + } + + func testAlreadyCorrectWithoutReplacementDerivesVerifyStep() { + let step = self.derived( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: true + ) + XCTAssertEqual(step, .verify) + } + + func testPronunciationMatchingBranchUsesEnrollmentCountNotConsecutiveCaptures() { + // Consecutive captures is 0 (irrelevant in this branch); enrollment count drives readiness. + let notReady = self.derived( + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 2, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + ) + XCTAssertEqual(notReady, .record) + + let ready = self.derived( + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 3, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + ) + XCTAssertEqual(ready, .verify) + } + + func testVerifyLockSurvivesPostReadyMissedCapture() { + // A miss after being ready: the latch must not snap back to .record. + let step = self.derived( + consecutiveCoveredCaptures: 0, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false, + hasReachedVerify: true + ) + XCTAssertEqual(step, .verify) + } + + func testPreloadedVariantsStateDerivesRecordStepNotVerify() { + let step = self.derived( + consecutiveCoveredCaptures: 0, + lastTrainingOutput: "", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .record) + } + + func testEmptyWordGuardOutranksVerifyLatch() { + // The empty-word guard must outrank the latch — the word-edit reset depends on it. + XCTAssertEqual(self.derived(word: "", hasReachedVerify: true), .word) + } + + func testCoveredCapturesAreCaseInsensitiveAgainstWord() { + let step = self.derived( + word: "FluidVoice", + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluidvoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: true + ) + XCTAssertEqual(step, .verify) + } + + // MARK: - finalOutputIsReady / alreadyCorrectWithoutReplacement + + private func finalReady( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false + ) -> Bool { + DictionaryTrainingStepModel.finalOutputIsReady( + DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ), + readyCoveredCount: self.readyCoveredCount + ) + } + + private func alreadyCorrect( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false + ) -> Bool { + DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( + DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ), + readyCoveredCount: self.readyCoveredCount + ) + } + + func testAlreadyCorrectRequiresNoCapturedVariants() { + // Variants remain, so there is something to save: ready, not already-correct. + XCTAssertFalse(self.alreadyCorrect( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + XCTAssertTrue(self.finalReady( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + } + + func testAlreadyCorrectImpliesFinalOutputNotReady() { + // Nothing to save means not ready — this is what keeps Save disabled. + let args = (3, "FluidVoice", true, true) + XCTAssertTrue(self.alreadyCorrect( + consecutiveCoveredCaptures: args.0, + lastTrainingOutput: args.1, + lastTrainingOutputIsCovered: args.2, + trainingVariantsIsEmpty: args.3 + )) + XCTAssertFalse(self.finalReady( + consecutiveCoveredCaptures: args.0, + lastTrainingOutput: args.1, + lastTrainingOutputIsCovered: args.2, + trainingVariantsIsEmpty: args.3 + )) + } + + func testFinalReadyForCoveredNonMatchingOutput() { + // Covered but output != word: a real replacement to save. + XCTAssertTrue(self.finalReady( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + XCTAssertFalse(self.alreadyCorrect( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + } + + func testPronunciationEnrollmentBoundary() { + XCTAssertFalse(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount - 1, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + )) + XCTAssertTrue(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + )) + } + + func testPronunciationAlreadyCorrectWithEnoughEnrollments() { + // Nothing to save and the output already matches: already-correct, not ready. + XCTAssertTrue(self.alreadyCorrect( + pronunciationEnrollmentCount: self.readyCoveredCount, + lastTrainingOutput: "FluidVoice", + trainingVariantsIsEmpty: true, + activePronunciationMatching: true + )) + XCTAssertFalse(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount, + lastTrainingOutput: "FluidVoice", + trainingVariantsIsEmpty: true, + activePronunciationMatching: true + )) + } + + // MARK: - resolveExpandedStep + + func testRecordingLockOverridesManualOverrideAndDerivedStep() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .verify, + manualOverride: .word, + isRecordingLocked: true, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .record) + } + + func testWordFieldFocusPinsWordStepEvenWithManualOverrideElsewhere() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: .verify, + isRecordingLocked: false, + isWordFieldFocused: true + ) + XCTAssertEqual(resolved, .word) + } + + func testManualOverrideWinsOverDerivedStepWhenNoLockOrFocus() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: .verify, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .verify) + } + + func testFallsBackToDerivedStepWithNoOverrideLockOrFocus() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: nil, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .record) + } + + func testRecordingLockWinsEvenWithWordFieldFocused() { + // Recording lock is priority 1, above word-field focus (priority 2). + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .word, + manualOverride: nil, + isRecordingLocked: true, + isWordFieldFocused: true + ) + XCTAssertEqual(resolved, .record) + } + + // MARK: - isStepInteractive + + func testVerifyHeaderIsNotTappableBeforeAnythingIsRecorded() { + // Opening Verify here would strand the user with Add Replacement disabled. + XCTAssertFalse(DictionaryTrainingStepModel.isStepInteractive( + .verify, + derived: .record, + isRecordingLocked: false, + wordIsEmpty: false + )) + } + + func testVerifyHeaderIsTappableOnceDerivedStepReachesVerify() { + XCTAssertTrue(DictionaryTrainingStepModel.isStepInteractive( + .verify, + derived: .verify, + isRecordingLocked: false, + wordIsEmpty: false + )) + } + + func testRecordAndVerifyHeadersAreLockedWhileWordIsEmpty() { + for step in [DictionaryTrainingStep.record, .verify] { + XCTAssertFalse(DictionaryTrainingStepModel.isStepInteractive( + step, + derived: .word, + isRecordingLocked: false, + wordIsEmpty: true + )) + } + XCTAssertTrue(DictionaryTrainingStepModel.isStepInteractive( + .word, + derived: .word, + isRecordingLocked: false, + wordIsEmpty: true + )) + } + + func testRecordingLockLeavesOnlyRecordTappable() { + XCTAssertTrue(DictionaryTrainingStepModel.isStepInteractive( + .record, + derived: .verify, + isRecordingLocked: true, + wordIsEmpty: false + )) + for step in [DictionaryTrainingStep.word, .verify] { + XCTAssertFalse(DictionaryTrainingStepModel.isStepInteractive( + step, + derived: .verify, + isRecordingLocked: true, + wordIsEmpty: false + )) + } + } + + // MARK: - Latched post-ready-miss progress + + /// Mirrors `CustomDictionaryView.trainingReadinessProgress` so the test sees the + /// same progress the view would show. + private func readinessProgress( + consecutiveCoveredCaptures: Int, + lastTrainingOutputIsCovered: Bool, + pronunciationEnrollmentCount: Int = 0, + activePronunciationMatching: Bool = false, + trainingVariantsIsEmpty: Bool = true, + lastTrainingOutput: String = "FluidVoice", + normalizedWord: String = "FluidVoice" + ) -> Int { + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: normalizedWord, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ) + let total = self.readyCoveredCount + if DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement(snapshot, readyCoveredCount: total) { + return total + } + let covered = DictionaryTrainingStepModel.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return covered ? min(snapshot.consecutiveCoveredCaptures, total) : 0 + } + + func testLatchedPostReadyMissKeepsVerifyExpandedButReadsZeroProgress() { + // The latch holds .verify while coverage resets; the ring must show real progress. + let total = self.readyCoveredCount + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: "FluidVoice", + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 0, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false, + activePronunciationMatching: false + ) + + let step = DictionaryTrainingStepModel.derivedStep( + snapshot, + readyCoveredCount: total, + hasReachedVerify: true + ) + XCTAssertEqual(step, .verify) + + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: step, + manualOverride: nil, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .verify) + + let progress = self.readinessProgress( + consecutiveCoveredCaptures: snapshot.consecutiveCoveredCaptures, + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: snapshot.trainingVariantsIsEmpty, + lastTrainingOutput: snapshot.lastTrainingOutput + ) + XCTAssertEqual(progress, 0, "latched post-ready-miss must read zero progress") + + // Verify stays tappable through the miss so Try Again remains reachable. + XCTAssertTrue(DictionaryTrainingStepModel.isStepInteractive( + .verify, + derived: step, + isRecordingLocked: false, + wordIsEmpty: false + )) + } +}