Skip to content

refactor(input): extract FocusStealCorrector with regression suite#4

Merged
hiking90 merged 3 commits into
fix/post-review-hardeningfrom
fix/focus-steal-corrector
May 15, 2026
Merged

refactor(input): extract FocusStealCorrector with regression suite#4
hiking90 merged 3 commits into
fix/post-review-hardeningfrom
fix/focus-steal-corrector

Conversation

@hiking90
Copy link
Copy Markdown
Owner

Summary

Extracts the focus-steal state machine from `OngeulInputController` into a dedicated `FocusStealCorrector` class, and adds 17 deterministic regression tests covering race scenarios the existing 78 tests don't reach.

Base branch: `fix/post-review-hardening` (PR #2) — this depends on the focus-steal hardening that lives there (H3 replay WorkItem, N5 forceKoreanForReplay signature). Will be rebased onto main once #2 lands.

What changes

Three commits, each a clean unit:

  1. `refactor: extract RecordedKey to top-level type` — Moves the `RecordedKey` struct out of `KeyEventTap` so it can be owned by the corrector without coupling to that class. Mechanical rename of 13 test references.

  2. `refactor: extract FocusStealCorrector` — The 86-line `correctFocusSteal()` method, six instance properties, the lifecycle cleanup in `activateServer`/`deactivateServer`, and the focus-steal guards in `handleKeyDown` all move into `FocusStealCorrector`. The controller adopts `FocusStealDelegate` to receive side-effect callbacks. No behavior change; 78 existing tests pass.

  3. `test: add FocusStealCorrector regression suite` — 17 new tests + test doubles (`ManualScheduler`, `FakeKeyEvidence`, `FakeModeController`, `SpyFocusStealDelegate`).

Design

The corrector depends on three injectable protocols:

  • `KeyEvidenceSource` — abstracts the external key buffer (`CGEventTap` in production)
  • `Scheduler` — abstracts time-based scheduling (`DispatchQueue.main` in production, `ManualScheduler` in tests)
  • `FocusStealModeController` — the hangul engine surface (`InputStateCoordinator` adopts it)

The controller becomes the `FocusStealDelegate`, exposing `applyResult`, `postSyntheticBackspaces`, `syncIconKorean`, `currentBundleId`, and `hasAttachedClient`. This keeps IMK/CGEvent specifics out of the corrector while preserving the existing call sites.

Regression scenarios covered

# Scenario
T1 Happy path: 2 keys → 10ms → 2 backspaces → replay
T2 Empty buffer: no-op
T3 Stale keys (>500ms): skip
T4 Late keys via evidence: included in backspace count
T5-T7 Keys via `handle()` during buffering / backspace / replay-pending: consumed
T8-T10 `cancel()` at each phase: no orphaned work
T11 bundleId change between enqueue and replay dispatch: skipped (PR #2 H3 regression test)
T12 Detached client at replay: skipped
T13 english→korean force path
T14 Already-korean: no force
T15 Double `startCorrection`: previous cancelled
T16 Synthetic backspace precedence over buffering guard
T17 nil keyLabel during buffering: consumed but not buffered

Stats

Lines
New files +800 (3 prod, 2 test)
`OngeulInputController.swift` -160 / +55
Test count 78 → 95

Test plan

  • `xcodebuild test` — 95 passed
  • `./scripts/build.sh --skip-bindgen` — successful
  • Manual: install IME and verify focus-steal corrects English keystrokes leaking into a new text field (Safari address bar, Mail composition)
  • Manual: verify normal Korean typing unaffected
  • Manual: verify mode toggle (Right Cmd / Shift+Space) unaffected
  • Manual: verify English Lock toggle unaffected

🤖 Generated with Claude Code

hiking90 and others added 3 commits May 14, 2026 22:05
RecordedKey was nested inside KeyEventTap, which forced callers to refer
to it as KeyEventTap.RecordedKey and coupled the type's visibility to
that class. Moving it to its own top-level type prepares for
FocusStealCorrector extraction (which will own RecordedKey-shaped data
without depending on KeyEventTap).

No behavior change. FocusStealTests updated to use the new bare name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 86-line correctFocusSteal() method, six instance properties, and the
focus-steal-related guards in handleKeyDown/activateServer/deactivateServer
are all moved out of OngeulInputController into a dedicated
FocusStealCorrector class.

The corrector owns its state machine and depends on three injectable
protocols:

- KeyEvidenceSource: the external key buffer (CGEventTap in production)
- Scheduler: time-based scheduling (DispatchQueue.main in production)
- FocusStealModeController: the hangul engine surface it needs

OngeulInputController now adopts FocusStealDelegate to receive the
side-effect callbacks (apply result, post synthetic backspaces, sync
icon, expose currentBundleId/client-attached).

No behavior change. All 78 existing tests pass. The 215-line diff is
~160 lines removed from the controller plus ~55 added to wire up the
corrector instance and the delegate extension. The corrector itself
lives in three new files (~400 lines total).

This enables deterministic regression tests for race scenarios that
the existing 78 tests don't reach (which will follow in the next
commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 deterministic tests cover the focus-steal state machine and the race
scenarios the existing 78 tests don't reach:

- Happy path: 2 keys buffered → 10ms wait → 2 synthetic backspaces →
  replay processes both keys
- Empty buffer / stale (>500ms) keys: corrector no-ops
- Late keys arriving during the 10ms wait via the evidence source: get
  added to the backspace count and replayed
- Keys arriving via handle() during buffering / backspace countdown /
  replay pending: all consumed and replayed in order
- nil keyLabel during buffering: consumed but not buffered (preserves
  the existing keyLabelFromEvent filtering behavior)
- cancel() at each lifecycle phase: no backspace post, no replay
- bundleId change between replay enqueue and dispatch: replay skipped
  (regression test for PR #2's H3 fix)
- Detached client at replay time: replay skipped
- english→korean force path: forceKoreanForReplay called once, mode
  transitions
- Already-korean path: forceKoreanForReplay not called
- Double startCorrection: previous task cancelled, only new fires
- Synthetic backspace handling takes precedence over buffering guard

Test infrastructure:
- ManualScheduler: advance(by:) controls time, runImmediates() triggers
  enqueued immediate work
- FakeKeyEvidence: queues batches of keys for sequential consumeKeys()
  calls (initial read + late check)
- FakeModeController: spies on processKey + forceKoreanForReplay
- SpyFocusStealDelegate: records all side-effect callbacks, with
  configurable bundleId and client-attached state

Total: 95 tests pass (78 existing + 17 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hiking90 hiking90 merged commit 9bb34b2 into fix/post-review-hardening May 15, 2026
hiking90 added a commit that referenced this pull request May 21, 2026
User report: after running ./scripts/install.sh and selecting CapsLock
as toggle key, the permission dialog appeared and the System Settings
pane opened — but Ongeul never appeared in the Input Monitoring list.

Root cause: macOS will only register an app in the TCC Input Monitoring
list when the app calls IOHIDManagerOpen AND its Info.plist contains
`NSInputMonitoringUsageDescription`. Without the key, the API call is
either silently rejected at registration time or the system refuses to
list the app for user grant — the on-demand registration flow is dead.

This was doc 32 § 미해결 #4 ("NSInputMonitoringUsageDescription IMK
적용성 — 미검증"). Now verified as required.

Changes:
- Info.plist: add `NSInputMonitoringUsageDescription` (Korean — matches
  CFBundleDevelopmentRegion = ko). Explains the CapsLock short/long
  press use and notes it's only used when toggleKey == .capsLock.
- Info.plist: add `NSAccessibilityUsageDescription` too. Ongeul has been
  working with Accessibility without it (older API path was permissive),
  but recent macOS versions are stricter; this is best practice and
  makes the System Settings prompt informative.
- design/32 § 미해결 #4 updated: mark verified + applied.

Plist validated with plutil -lint.

User recovery procedure after pulling this fix:
  1. ./scripts/install.sh  (rebuilds + reinstalls + tccutil reset)
  2. **Logout / login** (Info.plist changes require IMK cache flush)
  3. Open prefs → change toggleKey to CapsLock → OK
  4. App now appears in System Settings → Input Monitoring
  5. Toggle on → reselect input source → CapsLock long-press works

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hiking90 added a commit that referenced this pull request May 21, 2026
…an mode)

User report: in 영문→long→short→short sequence, the first short press
correctly exits real Caps Lock (LED off), but the second short press —
intended to toggle to Korean — instead turns the LED back on, looking
to the user like Caps Lock re-activated.

Root cause: design conflict between doc 30 SET semantics (LED ON =
Korean) and doc 32 HID realLockOn (LED ON = real Caps Lock active). In
HID mode, the LED was carrying two meanings:
- LED on for Korean mode (doc 30 SET via setMode/toggleEngineMode)
- LED on for realLockOn (doc 32 explicit setState in enterRealCapsLock)

Visually identical, semantically distinct. After exiting realLockOn and
then toggling to Korean, the LED turned on for the second reason and
the user (correctly) interpreted it as Caps Lock — conflicting with
the macOS convention "Caps Lock LED on = uppercase locked".

Fix: in HID mode, LED only reflects realLockOn. Mode indicator moves to
the menu bar icon entirely.

- InputStateCoordinator.setMode and toggleEngineMode: LED sync gate
  changed from `mode != .hidRealLockOn` to `mode == .cgEventTapAuthority`.
  doc 30 SET semantics still apply when HID isn't active (fallback);
  in HID mode, mode changes don't touch LED.
- OngeulInputController.performExitRealCapsLock: was `setState(korean)`
  (LED on if restored to Korean per doc 30 SET); now unconditionally
  `setState(false)` (LED off — restored mode shows via menu bar icon).
- doc 32 updated: § Doc 30과의 관계 clarifies the LED scope split,
  § 단일 상태 enum module table updated, ASCII state diagram note
  changed, transitions table includes LED column showing OFF/ON
  explicitly, 동작 시나리오 #1 and #4 reworded.

End-user behavior after this fix:
- 영문 → long → short → short → Korean
- LED: off → on(realLockOn) → off(exit) → off(toggle)
- Mode: English → English+caps → English → Korean
- LED never turns on for "just being in Korean mode" — only when real
  Caps Lock is active. Matches macOS convention; resolves user confusion.

Phase 1 PR (#13) unaffected — doc 30 SET continues to apply in the
CGEventTap-only baseline (no HID).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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