Skip to content

feat: practise by typing, not by pressing buttons - #41

Open
widgetii wants to merge 1 commit into
mainfrom
feat/m12-practice-flow
Open

widgetii wants to merge 1 commit into
mainfrom
feat/m12-practice-flow

Conversation

@widgetii

Copy link
Copy Markdown
Member

Answers your UX feedback on 听写. Your overarching point — "too many buttons I
need to click to work with this UX instead just typing to see my mistakes"
—
drove the shape of it.

Your point What changed
1. Chinese keyboard in the dialog The field asks the system for a zh-Hans input source while it has focus
2. Why click Check Marking fires when the answer is as long as the sentence
3. Why click Next phrase A correct answer moves on by itself
4. Why click Play again Arriving at a sentence plays it
5. Resume restarts the sentence Picks up ~2 s back, as a podcast player does
6. Cut mid-word Phrase playback now seeks exactly — see below
7. No pinyin for mistakes Every wrong character carries its reading, beside what you typed

On #6, which you asked me to double-check

I measured the stored boundaries against actual audio energy (10 ms frames,
speech = 6% of peak) on 听力64 and its narration:

lead-in  median 0.00s   mean 0.03s   max 0.23s
phrases starting >0.3s before any speech: 0 of 32

So the data is not systematically early there. The playback was:

let tolerance = CMTime(seconds: 0.1, preferredTimescale: 600)   // ±100 ms

That comment ("a tenth of a second is inaudible") is true for scrubbing a
narration and false when the target is the first syllable — and AVPlayer
resolves a tolerance by taking whichever sync sample is cheapest, in practice an
earlier one. Phrase playback seeks exactly now; scrubbing keeps the tolerance,
which is what it was written for.

I also suspected the editor drew the waveform against a different clock than the
handles. That was wrong — WaveformReader and the transport both read
duration from the same AVURLAsset. Discarded rather than reported as a finding.

Judgement calls worth disagreeing with

  • Only a correct answer advances. A wrong one stays, because the marking is
    the whole feedback the exercise gives. Say if you'd rather it always moved on.
  • Completeness counts scorable characters, not everything typed — so your
    IME's punctuation doesn't fire the check a character early.
  • The input-source switch is a request. allowedInputSourceLocales is
    honoured when a Simplified Chinese source is installed and ignored otherwise.
    An app that could force one could also strand somebody.

Verification, and what is not verified

13 new flow tests; every one of 10 planted mutants dies. Mutation testing
also found two redundant guards, which were removed rather than papered over.
Full suite green, lint clean, both app targets built.

Not verified on screen. Synthetic input stopped reaching the app partway
through this work — the cursor moves but clicks do not land, including on
controls unrelated to this change — so I could not drive the practice screen the
way I did for the earlier PRs. The behaviour is covered at the model layer, but
the feel of it, the input-source switch, and the pinyin layout are unconfirmed
by eye. Worth a look before merging.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Make dictation practice flow from typing and fix phrase playback

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Automatically play arriving phrases, mark complete answers, and advance after correct responses
 while retaining manual controls.
• Request Chinese input, show pinyin for mistakes, resume with a short rewind, and seek precisely at
 phrase starts.
• Add practice-flow tests and document the learner-facing changes.
Diagram

graph TD
  IME["Chinese Input"] --> FIELD["Writing Field"] --> COMPLETE{"Answer Complete?"} -->|yes| CHECK["Mark Answer"] --> CORRECT{"Correct Answer?"} -->|yes| NEXT["Next Phrase"] --> AUDIO["Phrase Playback"]
  CHECK --> FEEDBACK["Marked Feedback"]
  CORRECT -->|no| FEEDBACK
  FIELD -->|arrival| AUDIO
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Debounce completion-based marking
  • ➕ Gives learners a brief chance to correct an equally long but unfinished answer.
  • ➖ Delays feedback and makes the typing-driven flow less immediate.
2. Keep explicit Check and Next as the primary flow
  • ➕ Avoids inferring when the learner intends to submit or move on.
  • ➖ Preserves the repeated button presses this PR is meant to eliminate.

Recommendation: Keep the proposed immediate flow and its manual controls: it directly addresses the reported friction without removing an escape hatch. Check focus, IME composition, feedback layout, and phrase starts on screen and with audio before merging; consider a short debounce only if equal-length premature submissions occur in practice.

Files changed (13) +496 / -20

Enhancement (7) +262 / -9
DictationComparison.swiftExpose the count of scorable characters +10/-0

Expose the count of scorable characters

• Adds a count based on the comparison’s existing scorable-character rules so punctuation and spacing do not determine answer completeness.

Sources/HanReaderCore/Listening/DictationComparison.swift

ChineseInput.swiftRequest Chinese input while writing +47/-0

Request Chinese input while writing

• Adds a platform helper that requests the zh-Hans input locale through AppKit and clears the preference when focus leaves. The request does not force installation or selection of an unavailable source.

Sources/HanReaderPlatform/ChineseInput.swift

PlaybackModel+Phrase.swiftResume phrases with a rewind and seek exactly +42/-5

Resume phrases with a rewind and seek exactly

• Replaying a paused, already-selected phrase starts up to two seconds before the playhead instead of restarting, unless it is nearly finished. Phrase starts and resumes request exact seeks.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift

PracticeModel+Flow.swiftDrive practice from answer completion and phrase arrival +95/-0

Drive practice from answer completion and phrase arrival

• Marks complete answers, schedules cancellable advancement after correct ones, and plays phrases on arrival when practice is ready. Incorrect or revealed answers do not advance automatically.

Sources/HanReaderUI/Models/PracticeModel+Flow.swift

PracticeModel.swiftStore marking readings and manage pending advancement +18/-0

Store marking readings and manage pending advancement

• Retains dictionary readings from a check for feedback display and clears them when selecting another phrase. Adds an injectable wait and cancels pending automatic advancement on manual selection or close.

Sources/HanReaderUI/Models/PracticeModel.swift

DictationMarkedView.swiftShow pinyin beneath incorrect character marks +25/-1

Show pinyin beneath incorrect character marks

• Uses retained readings to display pinyin for mistaken characters beside the marked answer, reserving alignment space without adding readings under correct characters.

Sources/HanReaderUI/Screens/DictationMarkedView.swift

PracticeScreen.swiftConnect the writing field to automatic practice flow +25/-3

Connect the writing field to automatic practice flow

• Triggers marking on typing changes and playback on phrase arrival, requests Chinese input while the field is focused, and passes readings to the marked view. Updates the shortcut hint while retaining manual controls.

Sources/HanReaderUI/Screens/PracticeScreen.swift

Bug fix (2) +15 / -9
AudioEngine.swiftDistinguish exact phrase seeks from tolerant scrubbing +13/-7

Distinguish exact phrase seeks from tolerant scrubbing

• Extends the audio-engine seek interface with an exactness option. AVPlayer uses zero tolerance for exact seeks and retains the existing 100 ms tolerance otherwise.

Sources/HanReaderPlayback/AudioEngine.swift

PlaybackModel.swiftSelect seek precision by playback mode +2/-2

Select seek precision by playback mode

• Keeps tolerant seeks for whole-recording playback and requests exact seeks when moving within a selected phrase.

Sources/HanReaderPlayback/PlaybackModel.swift

Tests (3) +196 / -2
PlaybackSpies.swiftRecord seek precision in the playback spy +4/-1

Record seek precision in the playback spy

• Adapts the spy to the new audio-engine seek signature and records whether each seek requests exact positioning.

Tests/HanReaderPlaybackTests/PlaybackSpies.swift

PracticeFlowTests.swiftCover typing-driven practice decisions +191/-0

Cover typing-driven practice decisions

• Adds 13 model tests for answer completeness, automatic marking and advancement, cancellation, and playback on arrival, including punctuation and revealed-answer cases.

Tests/HanReaderUITests/PracticeFlowTests.swift

PracticeTestSupport.swiftAdapt the silent audio engine to the seek interface +1/-1

Adapt the silent audio engine to the seek interface

• Updates the test engine to accept the new exactness argument without changing its silent behavior.

Tests/HanReaderUITests/PracticeTestSupport.swift

Documentation (1) +23 / -0
CHANGELOG.mdDocument the revised dictation experience +23/-0

Document the revised dictation experience

• Describes typing-driven progression, rewind-on-resume, Chinese input preference, mistake readings, and precise phrase starts in release-note terms.

CHANGELOG.md

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (10) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Finished phrases replay only their tail 🐞 Bug ≡ Correctness
Description
resumeSegment() treats a phrase as finished only when the last reported playhead is within 50 ms
of its endpoint. Phrase completion does not update that playhead, which is reported at 250 ms
intervals, so pressing Play after completion or during a drill gap commonly starts about two seconds
before the end instead of at the beginning.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R61-65]

+        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)
Evidence
The new same-range decision enters resumeSegment() whenever playback is stopped.
finishSegmentPass() stops playback without setting playhead to the endpoint, while the engine
reports time every 0.25 seconds; the new 0.05-second test can therefore choose the rewind position
for a finished phrase.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[21-34]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-65]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[85-105]
Sources/HanReaderPlayback/AudioEngine.swift[52-58]
Sources/HanReaderPlayback/PlaybackModel.swift[393-409]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A completed phrase is mistaken for a paused phrase because the finish callback leaves a periodically reported playhead short of its end.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[22-34]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-65]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[85-96]
## Recommended Fix
Record completion when a phrase pass finishes and restart from its beginning on a subsequent Play; reserve rewind for an actual mid-phrase pause. Test replay with a playhead more than 50 ms short of the endpoint.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Going back to a correct sentence bounces you forward 🐞 Bug ≡ Correctness
Description
select() restores a saved answer into typed, but PracticeScreen treats that restoration as a
learner edit and calls answerChanged(). When the saved answer is complete and the result has been
hidden, selecting its phrase in the progress strip or pressing ⌘← triggers check() without a
keystroke, records another attempt, and moves the learner away after 1.2 seconds if the answer
passes.
Code

Sources/HanReaderUI/Screens/PracticeScreen.swift[R137-139]

+        .onChange(of: model.typed) { _, _ in
+            Task { await model.answerChanged() }
+        }
Evidence
select() assigns attempts[index]?.typed to typed, clears the result, and sets isRevealed to
false outside the editor. The screen’s .onChange(of: model.typed) calls answerChanged(), whose
ready, unrevealed, and complete-answer guards then pass; check() records an attempt because
hadLooked is false and schedules advance(by: 1) for a passing listening result. The
progress-strip click calls model.select in PracticeComponents.swift:20.

Sources/HanReaderUI/Models/PracticeModel.swift[221-239]
Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
Sources/HanReaderUI/Models/PracticeModel.swift[265-310]
Sources/HanReaderUI/Screens/PracticeComponents.swift[15-25]
Sources/HanReaderUI/Models/PracticeModel.swift[221-233]
Sources/HanReaderUI/Screens/PracticeScreen.swift[129-139]
Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-68]
Sources/HanReaderUI/Models/PracticeModel.swift[290-304]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Selecting a phrase restores its saved answer into `typed`, which triggers automatic checking as though the learner had edited the field. A complete saved answer can therefore record another attempt and, if correct, advance away from the phrase.

## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[221-239]
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
- Sources/HanReaderUI/Screens/PracticeScreen.swift[137-139]

## Recommended Fix
Distinguish restored answers from user-entered changes: record the restored value in `select()` (for example, as `restoredTyped`) and return early from `answerChanged()` while `typed == restoredTyped`. Add a navigation test that selects a phrase with a saved complete, correct answer and asserts that selection neither records another attempt nor advances the model.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Returning to a phrase can show stale marks 🐞 Bug ≡ Correctness
Description
answerChanged() accepts a completed check() when current again equals its original phrase
index, without knowing whether the reader left and returned while the lookup was pending. On that
navigation sequence, the old answer can replace the returned phrase’s result, record an attempt, and
schedule another automatic advance.
Code

Sources/HanReaderUI/Models/PracticeModel+Flow.swift[R50-54]

+        let phrase = current
+        await check()
+        guard current == phrase, let result else { return }
+        if result.listeningAccuracy >= 1 {
+            await advanceAfterPass(from: phrase)
Evidence
check() suspends after capturing the answer and later tests only the phrase index. select()
resets the displayed answer and result but does not invalidate an in-flight check, and the new
automatic path likewise tests only the index before scheduling advancement.

Sources/HanReaderUI/Models/PracticeModel.swift[221-233]
Sources/HanReaderUI/Models/PracticeModel.swift[267-304]
Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An automatic check started before navigation can finish after the reader leaves and returns to the same phrase index.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-55]
- Sources/HanReaderUI/Models/PracticeModel.swift[221-233]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-292]
## Recommended Fix
Associate each check with a selection generation or cancellable task, invalidate it on selection, and reject its result before marking, recording, or advancing if that generation has changed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Some mistakes show the wrong reading 🐞 Bug ≡ Correctness
Description
MarkView.pinyin displays the first dictionary reading for a wrong character without using its
context in the sentence. For a character whose sentence reading differs from its globally ranked
first reading, the new annotation teaches a pronunciation that does not match the audio.
Code

Sources/HanReaderUI/Screens/DictationMarkedView.swift[R157-159]

+        let character = mark.expected ?? mark.typed
+        guard let character, let reading = readings[character]?.first else { return " " }
+        return reading.display
Evidence
The view selects readings[character]?.first, and the supplied lookup is keyed solely by character;
the dictionary ranks those standalone candidates. The reader’s existing display path separately
computes reading context, demonstrating that standalone rank is not the sentence’s reading.

Sources/HanReaderUI/Screens/DictationMarkedView.swift[153-160]
Sources/HanReaderUI/Models/PracticeModel.swift[279-291]
Sources/HanReaderUI/Services/DictionaryService.swift[143-174]
Sources/HanReaderCore/Dictionary/LexiconBuilder.swift[20-32]
Sources/HanReaderUI/Models/ReaderModel.swift[253-269]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Wrong-character pinyin uses the first standalone dictionary reading even when the character has a different reading in the sentence.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[153-160]
- Sources/HanReaderUI/Models/PracticeModel.swift[279-291]
## Recommended Fix
Resolve expected-character readings in sentence context before passing them to the marked view. Keep standalone lookup only as a fallback where no contextual reading is available, and cover a polyphonic phrase in a test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Screen readers cannot hear mistake pinyin 🐞 Bug ≡ Correctness
Description
MarkView adds the pinyin as a child Text but keeps .accessibilityElement(children: .ignore)
and a label that omits that reading. VoiceOver users therefore receive the mistake classification
and characters but not the pronunciation supplied by this feature.
Code

Sources/HanReaderUI/Screens/DictationMarkedView.swift[R144-147]

+            Text(verbatim: pinyin)
+                .font(ReaderFont.ruby(style))
+                .foregroundStyle(.secondary)
+                .frame(minHeight: style.rubyReservation)
Evidence
The new pinyin Text is inside an accessibility element whose children are ignored. Its explicit
label describes outcomes and characters but never uses pinyin.

Sources/HanReaderUI/Screens/DictationMarkedView.swift[141-160]
Sources/HanReaderUI/Screens/DictationMarkedView.swift[178-191]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new pinyin text is hidden from accessibility because the mark ignores its children and provides a label without the reading.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[141-160]
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[178-191]
## Recommended Fix
Add the resolved reading to the accessible label for wrong marks, retaining the existing character and mistake information.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
6. The first manually split phrase stays silent 🐞 Bug ≡ Correctness
Description
playOnArrival() suppresses playback while isEditing is true, but manual splitting enters the
ready phase and opens the boundary editor before the writing view appears. Closing that editor does
not retry playback, so the first phrase requires an explicit Play or a move to another phrase.
Code

Sources/HanReaderUI/Models/PracticeModel+Flow.swift[R91-93]

+    func playOnArrival() {
+        guard phase == .ready, !isEditing, tapping == nil, result == nil else { return }
+        play()
Evidence
splitWithoutListening() sets phase = .ready and immediately calls startEditing(). The new
arrival handler returns while editing, and stopEditing() does not call it later.

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[116-140]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[39-61]
Sources/HanReaderUI/Models/PracticeModel+Flow.swift[84-94]
Sources/HanReaderUI/Screens/PracticeScreen.swift[118-132]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The initial arrival after manual splitting occurs while the boundary editor is open, and its suppressed playback is never retried.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[84-94]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[47-61]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[116-140]
## Recommended Fix
Keep playback suppressed during editing, but trigger it when the initial editor closes if the reader is still on the unplayed phrase. Cover the manual-split path in a flow test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Fast typing can record one answer twice 🐞 Bug ☼ Reliability
Description
Each change to typed starts a separate task, but answerChanged() uses !isRevealed as its only
guard and check() does not set isRevealed until its dictionary lookup returns. If another edit
arrives after the answer is complete but before that lookup finishes, both checks can record the
same phrase, schedule an advance, and race to save the answer.
Code

Sources/HanReaderUI/Models/PracticeModel+Flow.swift[R49-51]

+        guard phase == .ready, !isRevealed, isAnswerComplete else { return }
+        let phrase = current
+        await check()
Evidence
Every change to typed launches an independent task, and the completeness condition remains true if
more characters are entered. check() captures hadLooked before awaiting dictionary readings and
sets isRevealed only afterward, so two qualifying calls can both pass the guard with `hadLooked ==
false and reach recordAttempt`.

Sources/HanReaderUI/Models/PracticeModel.swift[265-310]
Sources/HanReaderUI/Screens/PracticeScreen.swift[137-139]
Sources/HanReaderUI/Screens/PracticeScreen.swift[133-139]
Sources/HanReaderUI/Models/PracticeModel+Flow.swift[35-55]
Sources/HanReaderUI/Models/PracticeModel.swift[267-304]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[184-200]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Rapid edits to a complete answer can start overlapping auto-checks before either reveals the result, recording the same phrase twice and racing to save the answer.

## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-304]

## Recommended Fix
Add an `isChecking` flag to the model. Set it synchronously before `await check()`, include `!isChecking` in the auto-check guard, clear the flag with `defer`, and reset it in `select`. Test two rapid complete-answer changes while the dictionary lookup is suspended to verify that only one check records the phrase.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Resuming a phrase restarts the drill's repeat count 🐞 Bug ≡ Correctness
Description
play(segment:drill:) calls cancelDrill(), which sets repeatsPlayed = 0, before the new
resuming branch calls resumeSegment(). Unlike the restart branch, the resume branch was meant to
keep the count. A learner who pauses part-way through repeat 3 of 3 and resumes is given a full set
of repeats again, so the drill no longer ends where it should.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R23-31]

+        let resuming = segment == range && !isPlaying
        cancelDrill()
        if let drill {
            self.drill = drill
        }
        segment = range
-        repeatsPlayed = 0
-        playSegmentOnce()
+        if resuming {
+            resumeSegment()
+        } else {
Evidence
cancelDrill zeroes repeatsPlayed, and finishSegmentPass compares repeatsPlayed with
drill.repeats to decide whether that pass was the last.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[90-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Resuming a paused phrase resets the drill's repeat counter, because `cancelDrill()` zeroes it before `resumeSegment()` runs.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[21-35]

## Recommended Fix
Capture `repeatsPlayed` before `cancelDrill()` and restore it in the resuming branch, or cancel only `drillTask` when resuming. Add a test that pauses during the second repeat, resumes, and asserts the total number of passes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

9. Scrubbing inside a phrase seeks exactly 🐞 Bug ➹ Performance
Description
seek(to:) passes exactly: segment != nil, so every scrub or skip that stays inside the current
phrase makes a frame-accurate seek. The PR description and the AudioEngine comment both say
scrubbing keeps the tolerance. Dragging the scrubber within a phrase therefore pays the expensive
exact seek on every move, which the old code avoided on purpose.
Code

Sources/HanReaderPlayback/PlaybackModel.swift[325]

+        engine.seek(to: target, exactly: segment != nil)
Evidence
seek(to:) serves both skip(by:) and scrubbing. While the target is inside the segment, segment
is still set, so the call is exactly: true.

Sources/HanReaderPlayback/PlaybackModel.swift[304-331]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Scrubs and skips inside the active phrase seek exactly, which contradicts the stated design and is expensive while dragging.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[325-325]

## Recommended Fix
Pass `exactly: false` here and keep exact seeks only for the start of phrase playback in `play(segment:from:)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. A doc comment line appears twice 🐞 Bug ⚙ Maintainability
Description
play(segment:drill:) now begins its doc comment with "Plays a single phrase, stopping exactly at
its end." on two consecutive lines. The original line was kept when the new doc block was added, so
the rendered documentation repeats its summary.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[15]

+    /// Plays a single phrase, stopping exactly at its end.
Evidence
Lines 14 and 15 of the new file are the same doc line.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[14-20]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The doc comment summary line is duplicated.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[14-15]

## Recommended Fix
Remove one of the two identical lines.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: Auto: 🧠 Deep: Multiple independent behavioral paths, platform integration, playback, UI, and async flow changes.

Grey Divider

Tip of the day
💡 Did you know, you can open the rule or relationship behind a finding in one click

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +61 to +65
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Finished phrases replay only their tail 🐞 Bug ≡ Correctness

resumeSegment() treats a phrase as finished only when the last reported playhead is within 50 ms
of its endpoint. Phrase completion does not update that playhead, which is reported at 250 ms
intervals, so pressing Play after completion or during a drill gap commonly starts about two seconds
before the end instead of at the beginning.
Agent Prompt
## Issue description
A completed phrase is mistaken for a paused phrase because the finish callback leaves a periodically reported playhead short of its end.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[22-34]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-65]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[85-96]
## Recommended Fix
Record completion when a phrase pass finishes and restart from its beginning on a subsequent Play; reserve rewind for an actual mid-phrase pause. Test replay with a playhead more than 50 ms short of the endpoint.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +50 to +54
let phrase = current
await check()
guard current == phrase, let result else { return }
if result.listeningAccuracy >= 1 {
await advanceAfterPass(from: phrase)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Returning to a phrase can show stale marks 🐞 Bug ≡ Correctness

answerChanged() accepts a completed check() when current again equals its original phrase
index, without knowing whether the reader left and returned while the lookup was pending. On that
navigation sequence, the old answer can replace the returned phrase’s result, record an attempt, and
schedule another automatic advance.
Agent Prompt
## Issue description
An automatic check started before navigation can finish after the reader leaves and returns to the same phrase index.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-55]
- Sources/HanReaderUI/Models/PracticeModel.swift[221-233]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-292]
## Recommended Fix
Associate each check with a selection generation or cancellable task, invalidate it on selection, and reject its result before marking, recording, or advancing if that generation has changed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +157 to +159
let character = mark.expected ?? mark.typed
guard let character, let reading = readings[character]?.first else { return " " }
return reading.display

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Some mistakes show the wrong reading 🐞 Bug ≡ Correctness

MarkView.pinyin displays the first dictionary reading for a wrong character without using its
context in the sentence. For a character whose sentence reading differs from its globally ranked
first reading, the new annotation teaches a pronunciation that does not match the audio.
Agent Prompt
## Issue description
Wrong-character pinyin uses the first standalone dictionary reading even when the character has a different reading in the sentence.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[153-160]
- Sources/HanReaderUI/Models/PracticeModel.swift[279-291]
## Recommended Fix
Resolve expected-character readings in sentence context before passing them to the marked view. Keep standalone lookup only as a fallback where no contextual reading is available, and cover a polyphonic phrase in a test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +144 to +147
Text(verbatim: pinyin)
.font(ReaderFont.ruby(style))
.foregroundStyle(.secondary)
.frame(minHeight: style.rubyReservation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Screen readers cannot hear mistake pinyin 🐞 Bug ≡ Correctness

MarkView adds the pinyin as a child Text but keeps .accessibilityElement(children: .ignore)
and a label that omits that reading. VoiceOver users therefore receive the mistake classification
and characters but not the pronunciation supplied by this feature.
Agent Prompt
## Issue description
The new pinyin text is hidden from accessibility because the mark ignores its children and provides a label without the reading.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[141-160]
- Sources/HanReaderUI/Screens/DictationMarkedView.swift[178-191]
## Recommended Fix
Add the resolved reading to the accessible label for wrong marks, retaining the existing character and mistake information.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +91 to +93
func playOnArrival() {
guard phase == .ready, !isEditing, tapping == nil, result == nil else { return }
play()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

6. The first manually split phrase stays silent 🐞 Bug ≡ Correctness

playOnArrival() suppresses playback while isEditing is true, but manual splitting enters the
ready phase and opens the boundary editor before the writing view appears. Closing that editor does
not retry playback, so the first phrase requires an explicit Play or a move to another phrase.
Agent Prompt
## Issue description
The initial arrival after manual splitting occurs while the boundary editor is open, and its suppressed playback is never retried.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[84-94]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[47-61]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[116-140]
## Recommended Fix
Keep playback suppressed during editing, but trigger it when the initial editor closes if the reader is still on the unplayed phrase. Cover the manual-split path in a flow test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +137 to +139
.onChange(of: model.typed) { _, _ in
Task { await model.answerChanged() }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Going back to a correct sentence bounces you forward 🐞 Bug ≡ Correctness

select() restores a saved answer into typed, but PracticeScreen treats that restoration as a
learner edit and calls answerChanged(). When the saved answer is complete and the result has been
hidden, selecting its phrase in the progress strip or pressing ⌘← triggers check() without a
keystroke, records another attempt, and moves the learner away after 1.2 seconds if the answer
passes.
Agent Prompt
## Issue description
Selecting a phrase restores its saved answer into `typed`, which triggers automatic checking as though the learner had edited the field. A complete saved answer can therefore record another attempt and, if correct, advance away from the phrase.

## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[221-239]
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
- Sources/HanReaderUI/Screens/PracticeScreen.swift[137-139]

## Recommended Fix
Distinguish restored answers from user-entered changes: record the restored value in `select()` (for example, as `restoredTyped`) and return early from `answerChanged()` while `typed == restoredTyped`. Add a navigation test that selects a phrase with a saved complete, correct answer and asserts that selection neither records another attempt nor advances the model.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +49 to +51
guard phase == .ready, !isRevealed, isAnswerComplete else { return }
let phrase = current
await check()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Fast typing can record one answer twice 🐞 Bug ☼ Reliability

Each change to typed starts a separate task, but answerChanged() uses !isRevealed as its only
guard and check() does not set isRevealed until its dictionary lookup returns. If another edit
arrives after the answer is complete but before that lookup finishes, both checks can record the
same phrase, schedule an advance, and race to save the answer.
Agent Prompt
## Issue description
Rapid edits to a complete answer can start overlapping auto-checks before either reveals the result, recording the same phrase twice and racing to save the answer.

## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-304]

## Recommended Fix
Add an `isChecking` flag to the model. Set it synchronously before `await check()`, include `!isChecking` in the auto-check guard, clear the flag with `defer`, and reset it in `select`. Test two rapid complete-answer changes while the dictionary lookup is suspended to verify that only one check records the phrase.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +23 to +31
let resuming = segment == range && !isPlaying
cancelDrill()
if let drill {
self.drill = drill
}
segment = range
repeatsPlayed = 0
playSegmentOnce()
if resuming {
resumeSegment()
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Resuming a phrase restarts the drill's repeat count 🐞 Bug ≡ Correctness

play(segment:drill:) calls cancelDrill(), which sets repeatsPlayed = 0, before the new
resuming branch calls resumeSegment(). Unlike the restart branch, the resume branch was meant to
keep the count. A learner who pauses part-way through repeat 3 of 3 and resumes is given a full set
of repeats again, so the drill no longer ends where it should.
Agent Prompt
## Issue description
Resuming a paused phrase resets the drill's repeat counter, because `cancelDrill()` zeroes it before `resumeSegment()` runs.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[21-35]

## Recommended Fix
Capture `repeatsPlayed` before `cancelDrill()` and restore it in the resuming branch, or cancel only `drillTask` when resuming. Add a test that pauses during the second repeat, resumes, and asserts the total number of passes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

engine.setStopTime(nil)
}
engine.seek(to: target)
engine.seek(to: target, exactly: segment != nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

9. Scrubbing inside a phrase seeks exactly 🐞 Bug ➹ Performance

seek(to:) passes exactly: segment != nil, so every scrub or skip that stays inside the current
phrase makes a frame-accurate seek. The PR description and the AudioEngine comment both say
scrubbing keeps the tolerance. Dragging the scrubber within a phrase therefore pays the expensive
exact seek on every move, which the old code avoided on purpose.
Agent Prompt
## Issue description
Scrubs and skips inside the active phrase seek exactly, which contradicts the stated design and is expensive while dragging.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[325-325]

## Recommended Fix
Pass `exactly: false` here and keep exact seeks only for the start of phrase playback in `play(segment:from:)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// MARK: - One phrase at a time

/// Plays a single phrase, stopping exactly at its end.
/// Plays a single phrase, stopping exactly at its end.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

10. A doc comment line appears twice 🐞 Bug ⚙ Maintainability

play(segment:drill:) now begins its doc comment with "Plays a single phrase, stopping exactly at
its end." on two consecutive lines. The original line was kept when the new doc block was added, so
the rendered documentation repeats its summary.
Agent Prompt
## Issue description
The doc comment summary line is duplicated.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[14-15]

## Recommended Fix
Remove one of the two identical lines.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant