From 8a80df0ab1cc54cf38a8286547d4ff06869ecdc5 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:40:43 +0300 Subject: [PATCH] feat: practise by typing, not by pressing buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen asked for four deliberate actions per sentence — play, check, grade, next — when three of them are inferable. A learner has finished an answer when it is as long as the sentence; an answer they heard correctly needs nothing said about it; and arriving at a sentence means they want to hear it. What is left is typing, which is the exercise. Nothing is removed. The transport, Check and the grades are all still there for the times the inference is not what was wanted; they are no longer the way through. **Marking fires on completeness**, counted in characters that will be scored rather than characters typed — an input method inserting its own punctuation must not bring the check forward, and a half-typed syllable sitting in the composition buffer is not in the field yet, so it cannot either. **A correct answer moves on** after a beat long enough to read the result; a wrong one stays, because the marking is the whole feedback the exercise gives and taking it away unasked is worse than a button. **Arriving plays**, except in the boundary editor, where arriving somewhere is a drag rather than a decision to practise. **Resuming rewinds.** Pausing a sentence and starting it again restarted it, so pausing to think near the end of a long one meant never reaching the end. It picks up two seconds back now, which is what a podcast player does and for the same reason. **Sentences no longer begin mid-word.** Phrase playback seeked with a tenth of a second of tolerance. That is right for dragging a scrubber through a narration and wrong when the target *is* the first syllable: AVPlayer resolves a tolerance by taking whichever sync sample is cheapest, in practice an earlier one. Phrase playback seeks exactly; scrubbing keeps its tolerance. **The field asks for a Chinese input source** while it has focus, so practising does not mean switching twice a sentence and finding out you forgot after the answer is marked. It is a request: the system honours it when a matching input source is installed and ignores it otherwise, which is the right shape for something that could otherwise strand somebody. **Mistakes carry their pinyin**, beside what was typed. The reading is the thing worth taking away from a wrong character, and seeing it next to the wrong one is what turns "wrong" into "those two sound alike". Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 23 +++ .../Listening/DictationComparison.swift | 10 + Sources/HanReaderPlatform/ChineseInput.swift | 47 +++++ Sources/HanReaderPlayback/AudioEngine.swift | 20 +- .../PlaybackModel+Phrase.swift | 47 ++++- Sources/HanReaderPlayback/PlaybackModel.swift | 4 +- .../Models/PracticeModel+Flow.swift | 95 +++++++++ .../HanReaderUI/Models/PracticeModel.swift | 18 ++ .../Screens/DictationMarkedView.swift | 26 ++- .../HanReaderUI/Screens/PracticeScreen.swift | 28 ++- .../PlaybackSpies.swift | 5 +- .../HanReaderUITests/PracticeFlowTests.swift | 191 ++++++++++++++++++ .../PracticeTestSupport.swift | 2 +- 13 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 Sources/HanReaderPlatform/ChineseInput.swift create mode 100644 Sources/HanReaderUI/Models/PracticeModel+Flow.swift create mode 100644 Tests/HanReaderUITests/PracticeFlowTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4e256..8eda7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ it needs to read like release notes rather than a commit log. ## [Unreleased] +### Changed + +- Listening practice is driven by typing rather than by buttons. The sentence + plays as soon as you reach it; your answer is marked the moment it is as long + as the sentence; and a correct one moves to the next sentence by itself. + Check, Play and Next phrase are all still there for the times that is not + what you wanted — they are no longer the way through. +- Pausing a sentence and starting it again picks up a couple of seconds before + where it stopped rather than at the beginning, the way a podcast player does. + Starting a long sentence over every time you paused to think made one + impossible to finish. +- The writing field asks the system for a Chinese input source while it has + focus, so practising does not mean switching input source twice a sentence. +- Every character you got wrong now carries its pinyin, beside what you typed — + the reading is the thing worth taking away from a mistake. + +### Fixed + +- Sentences no longer begin mid-word. Phrase playback seeked with a tenth of a + second of tolerance, which is inaudible when dragging a scrubber through a + narration and very audible when the target is the first syllable of a + sentence. It is exact now; scrubbing keeps the tolerance it wants. + ### Added - Listening and dictation practice (听力 / 听写). A text with a narration opens diff --git a/Sources/HanReaderCore/Listening/DictationComparison.swift b/Sources/HanReaderCore/Listening/DictationComparison.swift index 4759bc2..0fa017d 100644 --- a/Sources/HanReaderCore/Listening/DictationComparison.swift +++ b/Sources/HanReaderCore/Listening/DictationComparison.swift @@ -152,6 +152,16 @@ public struct DictationResult: Hashable, Sendable { /// for building this inside HanReader rather than using a general-purpose /// dictation tool — the readings are already here. public enum DictationComparison { + /// How many characters of this text count towards a score. + /// + /// Punctuation and spacing are not the exercise, so they are not in the + /// count — which is what lets a screen notice that an answer is as long + /// as the sentence without caring whether the learner's input method + /// inserted a comma. + public static func scorableCount(of text: String) -> Int { + scorable(text).count + } + /// Compares an attempt against the expected text. /// /// - Parameter readings: consulted only for characters that did not diff --git a/Sources/HanReaderPlatform/ChineseInput.swift b/Sources/HanReaderPlatform/ChineseInput.swift new file mode 100644 index 0000000..61a18fd --- /dev/null +++ b/Sources/HanReaderPlatform/ChineseInput.swift @@ -0,0 +1,47 @@ +// HanReader — MIT licensed. See LICENSE. + +#if os(macOS) + import AppKit +#endif + +/// Asking the system for a Chinese input source while a field is being typed +/// into. +/// +/// Listening practice is the one screen in the app where every keystroke is +/// meant to be Chinese. Leaving the learner to switch input source themselves +/// means doing it on the way in and again on the way out, every sentence, and +/// discovering they forgot only once the answer has been typed in Latin +/// letters and marked wrong. +/// +/// ### Asking, not forcing +/// +/// `allowedInputSourceLocales` is a *request*: the system honours it when the +/// learner has a matching input source installed, and does nothing when they +/// have not. That is the right shape — an app that could force an input +/// source could also strand somebody with no way to type their own language — +/// and it is why this reports nothing back. There is no failure here to +/// handle, only a preference the system may or may not act on. +public enum ChineseInput { + /// The locale to prefer. Simplified, matching the dictionary the app + /// bundles and the transcripts it is built for. + public static let locale = "zh-Hans" + + /// Prefers a Chinese input source for whatever is being typed into now. + /// + /// Call it as the writing field takes focus. The preference belongs to + /// the focused field's input context, so it lasts as long as that field + /// is first responder and the system restores the learner's own choice + /// afterwards. + public static func prefer() { + #if os(macOS) + NSTextInputContext.current?.allowedInputSourceLocales = [locale] + #endif + } + + /// Gives the choice back, for a field that is no longer the exercise. + public static func clearPreference() { + #if os(macOS) + NSTextInputContext.current?.allowedInputSourceLocales = nil + #endif + } +} diff --git a/Sources/HanReaderPlayback/AudioEngine.swift b/Sources/HanReaderPlayback/AudioEngine.swift index f0e6cf9..64ffcc6 100644 --- a/Sources/HanReaderPlayback/AudioEngine.swift +++ b/Sources/HanReaderPlayback/AudioEngine.swift @@ -24,7 +24,10 @@ public protocol AudioEngine: AnyObject { func load(_ url: URL) func play() func pause() - func seek(to time: Double) + /// - Parameter exactly: land on the requested time rather than wherever + /// is cheap. Scrubbing a narration does not care; starting a phrase + /// does, because the target *is* the first syllable. + func seek(to time: Double, exactly: Bool) /// Applied immediately when playing, and remembered for the next `play()` /// when not. func setRate(_ rate: Double) @@ -173,13 +176,16 @@ final class AVPlayerAudioEngine: AudioEngine { player.pause() } - func seek(to time: Double) { + func seek(to time: Double, exactly: Bool) { let target = CMTime(seconds: max(0, time), preferredTimescale: 600) - // A tolerance, deliberately. The prototype seeked with - // `toleranceBefore: .zero, toleranceAfter: .zero`, which forces a - // frame-accurate seek — expensive, and pointless for spoken audio - // where a tenth of a second is inaudible. - let tolerance = CMTime(seconds: 0.1, preferredTimescale: 600) + // A tenth of a second is inaudible when the reader is dragging a + // scrubber through a narration, and a frame-accurate seek there is + // expensive for nothing. It is *not* inaudible at the start of a + // phrase: that target is the first syllable, AVPlayer resolves a + // tolerance by landing on whichever sync sample is cheapest — in + // practice an earlier one — and the reader hears the sentence + // begin mid-word. + let tolerance = exactly ? .zero : CMTime(seconds: 0.1, preferredTimescale: 600) player.seek(to: target, toleranceBefore: tolerance, toleranceAfter: tolerance) } diff --git a/Sources/HanReaderPlayback/PlaybackModel+Phrase.swift b/Sources/HanReaderPlayback/PlaybackModel+Phrase.swift index 5877670..0ae48dd 100644 --- a/Sources/HanReaderPlayback/PlaybackModel+Phrase.swift +++ b/Sources/HanReaderPlayback/PlaybackModel+Phrase.swift @@ -12,15 +12,26 @@ extension PlaybackModel { // MARK: - One phrase at a time /// Plays a single phrase, stopping exactly at its end. + /// Plays a single phrase, stopping exactly at its end. + /// + /// Asking for the phrase that is already loaded picks it up where it + /// stopped, a little way back, rather than starting it again — the + /// difference between pausing to think halfway through a long sentence + /// and never getting to the end of one. public func play(segment range: Range, drill: Drill? = nil) { guard state == .ready else { return } + let resuming = segment == range && !isPlaying cancelDrill() if let drill { self.drill = drill } segment = range - repeatsPlayed = 0 - playSegmentOnce() + if resuming { + resumeSegment() + } else { + repeatsPlayed = 0 + playSegmentOnce() + } } /// Goes back to playing the whole recording. @@ -31,13 +42,39 @@ extension PlaybackModel { onStateChange?() } + /// How far back a resumed phrase rewinds. + /// + /// Picking up exactly where the sound stopped drops the listener into + /// the middle of a word with no run-up, and starting the phrase again + /// makes a long sentence impossible to finish — pause once near the end + /// and you are back at the beginning. Podcasts solves it with a short + /// rewind, and so does this. + public static let resumeRewind = 2.0 + func playSegmentOnce() { + play(segment: segment, from: nil) + } + + /// Picks the phrase up again from a little before where it stopped. + func resumeSegment() { + guard let segment else { return } + let from = max(segment.lowerBound, playhead - Self.resumeRewind) + // Close enough to the start that a rewind would land there anyway, + // or past the end after a finished pass: begin the phrase again. + let isNearlyOver = playhead >= segment.upperBound - 0.05 + play(segment: segment, from: isNearlyOver ? nil : from) + } + + private func play(segment: Range?, from: Double?) { guard let segment, state == .ready else { return } activateSession() engine.setStopTime(segment.upperBound) - engine.seek(to: segment.lowerBound) - playhead = segment.lowerBound - observedTime = segment.lowerBound + let start = from ?? segment.lowerBound + // Exactly, because the target is the first syllable. A tolerance + // here is what makes a sentence begin mid-word. + engine.seek(to: start, exactly: true) + playhead = start + observedTime = start engine.play() isPlaying = true clearPauseReasons() diff --git a/Sources/HanReaderPlayback/PlaybackModel.swift b/Sources/HanReaderPlayback/PlaybackModel.swift index db45cb3..502f5cc 100644 --- a/Sources/HanReaderPlayback/PlaybackModel.swift +++ b/Sources/HanReaderPlayback/PlaybackModel.swift @@ -219,7 +219,7 @@ public final class PlaybackModel { playhead = start observedTime = start if start > 0 { - engine.seek(to: start) + engine.seek(to: start, exactly: false) } engine.setRate(rate.rawValue) onStateChange?() @@ -322,7 +322,7 @@ public final class PlaybackModel { self.segment = nil engine.setStopTime(nil) } - engine.seek(to: target) + engine.seek(to: target, exactly: segment != nil) playhead = target // Published immediately rather than left to the next report, or the // thumb springs back to where it was for a quarter of a second. diff --git a/Sources/HanReaderUI/Models/PracticeModel+Flow.swift b/Sources/HanReaderUI/Models/PracticeModel+Flow.swift new file mode 100644 index 0000000..a62e733 --- /dev/null +++ b/Sources/HanReaderUI/Models/PracticeModel+Flow.swift @@ -0,0 +1,95 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPlayback + +/// Practising without pressing anything. +/// +/// The screen this grew out of asked for four deliberate actions per +/// sentence: play, check, grade, next. Three of them are inferable. A +/// learner has finished an answer when it is as long as the sentence; an +/// answer they heard correctly needs nothing said about it; and arriving at +/// a phrase means they want to hear it. What is left is typing, which is the +/// exercise. +/// +/// Nothing here removes a control — the transport, Check and the grades are +/// all still there for the cases the inference gets wrong. They stop being +/// the way through. +extension PracticeModel { + /// How long a passed phrase stays on screen before the next one. + /// + /// Long enough to read "8 of 8 correct" and see the marking, short + /// enough that it does not feel like waiting. Moving on instantly would + /// take the result away before it had been seen, which is the one piece + /// of feedback the exercise exists to give. + static let passPause = 1.2 + + /// Whether the answer is as long as the sentence. + /// + /// The signal that a learner has finished. Counting the characters that + /// will be scored rather than every character typed, so an input method + /// inserting its own punctuation does not hold the check back — and a + /// half-finished Pinyin syllable sitting in the IME's composition + /// buffer is not in the field yet, so it cannot trigger one either. + var isAnswerComplete: Bool { + let expected = DictationComparison.scorableCount(of: text) + return expected > 0 && DictationComparison.scorableCount(of: typed) >= expected + } + + /// Called whenever the writing field changes. + /// + /// Checks as soon as the answer is long enough, and — if it was right — + /// moves on by itself. A learner who typed the sentence correctly has + /// said everything there is to say about it. + func answerChanged() async { + // `!isRevealed` covers an answer already marked as well as one the + // reader gave up on: checking always reveals, so a phrase with a + // result behind it is a revealed phrase. + guard phase == .ready, !isRevealed, isAnswerComplete else { return } + let phrase = current + await check() + guard current == phrase, let result else { return } + if result.listeningAccuracy >= 1 { + await advanceAfterPass(from: phrase) + } + } + + /// Moves on from a phrase that was answered correctly. + private func advanceAfterPass(from phrase: Int) async { + advanceTask?.cancel() + advanceTask = Task { [weak self] in + await self?.wait(Self.passPause) + guard let self, !Task.isCancelled, current == phrase else { return } + // Only if they have not moved themselves in the meantime. The + // end of the list needs no guard of its own: `advance` clamps, + // so the last phrase stays where it is. + advance(by: 1) + advanceTask = nil + } + } + + /// Waits for a pending move to land, so a test asserts where the reader + /// ended up rather than racing it. + func awaitPendingAdvance() async { + await advanceTask?.value + } + + /// Stops a pending move, for a learner who did something else first. + func cancelAdvance() { + advanceTask?.cancel() + advanceTask = nil + } + + /// Plays the phrase the reader has just arrived at. + /// + /// The whole screen is "listen, then write", so arriving at a phrase and + /// having to ask for the sound is a step that is never not wanted. Held + /// back while the boundary editor is open, where arriving somewhere is + /// a drag rather than a decision to practise, and while tapping along, + /// which is already playing the whole recording. + func playOnArrival() { + guard phase == .ready, !isEditing, tapping == nil, result == nil else { return } + play() + } +} diff --git a/Sources/HanReaderUI/Models/PracticeModel.swift b/Sources/HanReaderUI/Models/PracticeModel.swift index 901142b..cf6b9f5 100644 --- a/Sources/HanReaderUI/Models/PracticeModel.swift +++ b/Sources/HanReaderUI/Models/PracticeModel.swift @@ -61,6 +61,13 @@ final class PracticeModel { var current = 0 /// The last check, or nil if this phrase has not been answered yet. private(set) var result: DictationResult? + /// What the characters in the last check sound like. + /// + /// Kept from the check rather than looked up again by the view: the + /// lookup has already happened — marking a homophone is what it is for — + /// and a view that fetches is a view that cannot be rendered from a + /// value. + private(set) var readings: [Character: [CharacterReading]] = [:] /// Whether the transcript is on screen. Off until checked, because the /// answer being visible is the one thing that makes dictation worthless. private(set) var isRevealed = false @@ -100,6 +107,13 @@ final class PracticeModel { /// files, and `private` in Swift reaches extensions in the *same* file /// only. var waveformTask: Task? + /// A pending move to the next phrase after a correct answer. + var advanceTask: Task? + /// How the flow waits, injectable so a test asserts order rather than + /// the clock — the lesson the drill's gap already taught. + var wait: @MainActor (Double) async -> Void = { seconds in + try? await Task.sleep(for: .seconds(seconds)) + } init(textID: TextID, services: AppServices, playback: PlaybackModel) { self.textID = textID @@ -148,6 +162,7 @@ final class PracticeModel { alignTask = nil waveformTask?.cancel() waveformTask = nil + cancelAdvance() // Through the same path the Stop button takes, rather than clearing // the flag here: dismissing the sheet part-way through tapping means // the same thing as stopping, and two ways to stop is two places to @@ -205,9 +220,11 @@ final class PracticeModel { func select(_ index: Int) { guard phrases.contains(where: { $0.index == index }) else { return } + cancelAdvance() current = index typed = attempts[index]?.typed ?? "" result = nil + readings = [:] // Hidden again — unless the boundary editor is open, which shows // the phrase's text because a boundary cannot be placed without // knowing which words it is meant to contain. Leaving the flag @@ -271,6 +288,7 @@ final class PracticeModel { readings: { readings[$0] ?? [] }, ) result = outcome + self.readings = readings isRevealed = true // A reader who showed the answer and then copied it has not // demonstrated anything about their listening, and recording a pass diff --git a/Sources/HanReaderUI/Screens/DictationMarkedView.swift b/Sources/HanReaderUI/Screens/DictationMarkedView.swift index 24da514..a79f4f6 100644 --- a/Sources/HanReaderUI/Screens/DictationMarkedView.swift +++ b/Sources/HanReaderUI/Screens/DictationMarkedView.swift @@ -15,6 +15,13 @@ struct DictationMarkedView: View { let result: DictationResult let expected: String let style: ReaderStyle + /// What each character sounds like, for the ones that were wrong. + /// + /// A learner looking at a mistake needs to know what the character they + /// missed actually sounds like — that is the thing worth taking away + /// from it — and seeing it beside what they typed is what turns "wrong" + /// into "those two sound alike, I picked the wrong one". + let readings: [Character: [CharacterReading]] var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -33,7 +40,7 @@ struct DictationMarkedView: View { private var marks: some View { FlowRow(spacing: 2) { ForEach(Array(result.marks.enumerated()), id: \.offset) { _, mark in - MarkView(mark: mark, style: style) + MarkView(mark: mark, style: style, readings: readings) } } } @@ -111,6 +118,7 @@ struct DictationMarkedView: View { private struct MarkView: View { let mark: DictationMark let style: ReaderStyle + let readings: [Character: [CharacterReading]] var body: some View { VStack(spacing: 1) { @@ -130,11 +138,27 @@ private struct MarkView: View { RoundedRectangle(cornerRadius: ReaderMetrics.tokenCornerRadius) .fill(ReaderColor.dictationFill(mark.outcome)) } + // And underneath, how the character they missed is said. Only + // under the wrong ones: pinyin under every character would be + // the reading surface, and this is a marking. + Text(verbatim: pinyin) + .font(ReaderFont.ruby(style)) + .foregroundStyle(.secondary) + .frame(minHeight: style.rubyReservation) } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibility) } + /// How the expected character is said, or how the typed one is when + /// there was nothing to expect. + private var pinyin: String { + guard mark.outcome != .correct else { return " " } + let character = mark.expected ?? mark.typed + guard let character, let reading = readings[character]?.first else { return " " } + return reading.display + } + /// A missing character is shown as an underscore rather than omitted: /// the gap is the information. private var displayed: String { diff --git a/Sources/HanReaderUI/Screens/PracticeScreen.swift b/Sources/HanReaderUI/Screens/PracticeScreen.swift index 624794c..cd98e32 100644 --- a/Sources/HanReaderUI/Screens/PracticeScreen.swift +++ b/Sources/HanReaderUI/Screens/PracticeScreen.swift @@ -1,6 +1,7 @@ // HanReader — MIT licensed. See LICENSE. import HanReaderCore +import HanReaderPlatform import HanReaderPlayback import SwiftUI @@ -72,6 +73,7 @@ struct PracticeScreen: View { result: result, expected: model.text, style: style, + readings: model.readings, ) GradeRow(model: model) } else if model.isRevealed { @@ -108,13 +110,33 @@ struct PracticeScreen: View { .buttonStyle(.borderless) .font(.caption) Spacer() - Text("⌘↩ check · ⌘P play · ⌘→ next") + Text("⌘P play · ⌘→ next · ⌘↩ check early") .font(.caption) .foregroundStyle(.tertiary) } } - .onAppear { isWriting = true } - .onChange(of: model.current) { _, _ in isWriting = true } + .onAppear { + isWriting = true + model.playOnArrival() + } + // Every keystroke on this screen is meant to be Chinese, so the + // system is asked for a Chinese input source while the field holds + // focus rather than leaving the learner to switch twice a sentence + // and find out they forgot after the answer is marked wrong. + .onChange(of: isWriting) { _, writing in + writing ? ChineseInput.prefer() : ChineseInput.clearPreference() + } + .onChange(of: model.current) { _, _ in + isWriting = true + model.playOnArrival() + } + // Typing is the whole interaction. The answer is marked as soon as + // it is as long as the sentence, and a correct one moves on by + // itself — Check and Next phrase stay for the times that is not + // what was wanted. + .onChange(of: model.typed) { _, _ in + Task { await model.answerChanged() } + } } private var revealed: some View { diff --git a/Tests/HanReaderPlaybackTests/PlaybackSpies.swift b/Tests/HanReaderPlaybackTests/PlaybackSpies.swift index 5da8262..ee2db5f 100644 --- a/Tests/HanReaderPlaybackTests/PlaybackSpies.swift +++ b/Tests/HanReaderPlaybackTests/PlaybackSpies.swift @@ -11,6 +11,8 @@ import Foundation @MainActor final class SpyEngine: AudioEngine { private(set) var seeks: [Double] = [] + /// Whether each seek asked to land exactly, which is what a phrase needs. + private(set) var exactSeeks: [Bool] = [] private(set) var playCount = 0 private(set) var pauseCount = 0 private(set) var rates: [Double] = [] @@ -36,8 +38,9 @@ final class SpyEngine: AudioEngine { pauseCount += 1 } - func seek(to time: Double) { + func seek(to time: Double, exactly: Bool) { seeks.append(time) + exactSeeks.append(exactly) currentTime = time } diff --git a/Tests/HanReaderUITests/PracticeFlowTests.swift b/Tests/HanReaderUITests/PracticeFlowTests.swift new file mode 100644 index 0000000..399aeee --- /dev/null +++ b/Tests/HanReaderUITests/PracticeFlowTests.swift @@ -0,0 +1,191 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPersistence +import HanReaderPlayback +import Testing +@testable import HanReaderUI + +/// Practising without pressing anything: the answer marks itself when it is +/// finished, a correct one moves on, and arriving at a phrase plays it. +@MainActor +@Suite("Practice flow") +struct PracticeFlowTests { + private typealias Support = PracticeTestSupport + + /// A model whose waits return at once, so these assert the order things + /// happen in rather than the clock they happen on. + private func flowing() async throws -> (model: PracticeModel, engine: SilentEngine) { + let (services, id) = try await Support.services(content: Support.content) + try await services.library.savePhrases( + (0 ..< 3).map { Support.phrase($0) }, + for: id, + ) + let engine = SilentEngine() + let playback = PlaybackModel(engine: engine, session: SilentSession(), wait: { _ in }) + let model = PracticeModel(textID: id, services: services, playback: playback) + model.wait = { _ in } + await model.load() + engine.onDuration?(30) + return (model, engine) + } + + // MARK: - Knowing when an answer is finished + + @Test("An answer as long as the sentence is finished") + func completeness() async throws { + let (model, _) = try await flowing() + #expect(!model.isAnswerComplete) + + model.typed = "第一句" + #expect(!model.isAnswerComplete) + + model.typed = "第一句话" + #expect(model.isAnswerComplete) + } + + /// Counting what will be scored, not what was typed: an input method + /// inserting its own punctuation must not hold the check back, and must + /// not trigger one early either. + @Test("Punctuation does not count towards being finished") + func punctuationDoesNotCount() async throws { + let (model, _) = try await flowing() + model.typed = "第一句," + #expect(!model.isAnswerComplete) + } + + // MARK: - Checking without being asked + + @Test("Finishing the answer marks it") + func finishingChecks() async throws { + let (model, _) = try await flowing() + model.typed = "第一句" + await model.answerChanged() + #expect(model.result == nil) + + model.typed = "第一句话" + await model.answerChanged() + #expect(model.result != nil) + #expect(model.attempts[0]?.typed == "第一句话") + } + + /// A reader who gave up and read the transcript must not have it marked + /// for them the moment they copy a character into the field. + @Test("A revealed phrase is not marked automatically") + func revealedPhrasesAreNotChecked() async throws { + let (model, _) = try await flowing() + model.reveal() + model.typed = model.text + await model.answerChanged() + #expect(model.result == nil) + } + + @Test("An answer already marked is not marked again") + func noSecondCheck() async throws { + let (model, _) = try await flowing() + model.typed = model.text + await model.answerChanged() + let attempts = model.attempts[0]?.attempts + + model.typed = model.text + "话" + await model.answerChanged() + #expect(model.attempts[0]?.attempts == attempts) + } + + /// A phrase with nothing in it is not an answer waiting to be marked. + /// Without this, an empty sentence counts as finished the moment the + /// screen opens. + @Test("A phrase with no text is never finished") + func emptyPhraseIsNotComplete() async throws { + let (services, id) = try await Support.services(content: Support.content) + let model = Support.model(textID: id, services: services) + await model.load() + #expect(model.text.isEmpty) + #expect(!model.isAnswerComplete) + } + + // MARK: - Moving on + + @Test("A correct answer moves to the next phrase by itself") + func correctAnswerAdvances() async throws { + let (model, _) = try await flowing() + model.typed = model.text + await model.answerChanged() + await model.awaitPendingAdvance() + #expect(model.current == 1) + } + + /// A wrong answer stays put. The marking is the point of the exercise + /// and taking it away unasked is the one thing worse than an extra + /// button. + @Test("A wrong answer stays where it is") + func wrongAnswerStays() async throws { + let (model, _) = try await flowing() + model.typed = "第二句话" + await model.answerChanged() + await model.awaitPendingAdvance() + #expect(model.current == 0) + #expect(model.result != nil) + } + + @Test("The last phrase does not move on") + func lastPhraseStays() async throws { + let (model, _) = try await flowing() + model.select(2) + model.typed = model.text + await model.answerChanged() + await model.awaitPendingAdvance() + #expect(model.current == 2) + } + + /// Moving by hand during the pause after a correct answer wins. The + /// automatic move is a convenience and must never fight the reader. + @Test("Moving by hand cancels a pending move") + func manualMoveWins() async throws { + let (model, _) = try await flowing() + model.wait = { _ in try? await Task.sleep(for: .milliseconds(50)) } + model.typed = model.text + await model.answerChanged() + + // Moved to phrase 2 themselves while the pause was still running. A + // stale move would take them on to phrase 3 — one they never asked + // for and did not see — so phrase 1 is deliberately not the target + // here: landing there would be indistinguishable from the automatic + // move having worked. + model.select(1) + await model.awaitPendingAdvance() + try await Task.sleep(for: .milliseconds(120)) + #expect(model.current == 1) + } + + // MARK: - Playing on arrival + + @Test("Arriving at a phrase plays it") + func arrivalPlays() async throws { + let (model, _) = try await flowing() + model.playOnArrival() + #expect(model.playback.isPlaying) + } + + /// The editor is for placing boundaries, where arriving somewhere is a + /// drag rather than a decision to practise. + @Test("The boundary editor does not play on arrival") + func editorDoesNotPlay() async throws { + let (model, _) = try await flowing() + model.startEditing() + model.playOnArrival() + #expect(!model.playback.isPlaying) + } + + @Test("A phrase already answered does not play again") + func answeredPhrasesDoNotReplay() async throws { + let (model, _) = try await flowing() + model.typed = model.text + await model.check() + model.playback.pause() + + model.playOnArrival() + #expect(!model.playback.isPlaying) + } +} diff --git a/Tests/HanReaderUITests/PracticeTestSupport.swift b/Tests/HanReaderUITests/PracticeTestSupport.swift index b13538e..07d0fed 100644 --- a/Tests/HanReaderUITests/PracticeTestSupport.swift +++ b/Tests/HanReaderUITests/PracticeTestSupport.swift @@ -137,7 +137,7 @@ final class SilentEngine: AudioEngine { func load(_: URL) {} func play() {} func pause() {} - func seek(to _: Double) {} + func seek(to _: Double, exactly _: Bool) {} func setRate(_: Double) {} func setStopTime(_: Double?) {} }