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