mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
test(terminal): pin the preedit overlay against already-committed cells
STA-3132 (arm A), STA-3170 and STA-3232 report a Korean preedit painted on top of text already on screen. Builds v1.4.163-v1.4.166 cancel the pending finalizer in compositionstart, so a committed syllable reaches onData one syllable late and buffer.x is stale — the overlay lands on the cell the flushed syllable is about to occupy. Replays a recorded hardware trace rather than an authored one: the ordered DOM event stream captured on Windows + MS Korean (wave5-r2 evidence, 64 events), echoing onData back as PTY output. The load-bearing assertion is deliberately not the obvious one. Comparing overlay style.left against cursorX is tautological — left is computed from buffer.x. This counts committed syllables from the compositionend events the IME fired, so the two sides are independently derived. Discriminated by a historical re-add across seven real bundles, since the owner is deletion-shaped: pristine beta287, v1.4.155 and v1.4.162 pass; v1.4.163 fails; v1.4.163 with that single call removed passes; the byte identical baseline restored fails again; head passes. Every failing arm fails only this case — the ordinary negative stays green in all seven. The negative asserts its own category rather than claiming it: zero composition events, zero isComposing, zero keyCode 229, exactly 16 events, paired against the Korean arm's 4 starts / 3 ends / 11 updates / 64 events. Scope: cell indices, not pixels. happy-dom has no layout, so the recorded 8x16 cell metrics are supplied to the render service. This makes no claim about pixels visually overlapping; that is affected-OS confirmation and stays open. Covers the overlap arm only — STA-3132's auto-line-break arm and STA-3232's half-line-capacity and a11y arms are untouched.
This commit is contained in:
+393
@@ -0,0 +1,393 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* STA-3132 (overlap arm) / STA-3170 / STA-3232 (overlap arm) — the Korean preedit is
|
||||
* painted on a cell that a committed-but-unflushed syllable is about to occupy.
|
||||
*
|
||||
* Recorded shape: Windows 11 / MS Korean 2-Set, `가나다라` injected one key at a time with
|
||||
* real scan codes at a `KO> ` prompt in Orca's terminal, DOM events and geometry recorded
|
||||
* between keystrokes on two shipped builds. The event stream replayed below is the one in
|
||||
* `.tmp/ime-handoff/swarm-scratch/wave5-r2/evidence/ko164.json.events`, in its recorded order.
|
||||
* Raw capture: `.tmp/ime-handoff/swarm-scratch/wave5-r2/evidence/{ko164,ko176,en176}.json`.
|
||||
*
|
||||
* build cursorX per key row while 라 composes
|
||||
* v1.4.176 (fixed) 4,4,4,6,6,8,8,10 `KO> 가나다`
|
||||
* v1.4.164 (defect) 4,4,4,4,4,6,6,8 `KO> 가나`
|
||||
*
|
||||
* The IME has committed three syllables in both runs (3 compositionend events), but on
|
||||
* 164 only two reached the screen, so the overlay for 라 sits at column 8 — exactly the
|
||||
* cell 다 lands in when it finally flushes. That collision is the reported overlap.
|
||||
*
|
||||
* The assertion is therefore between the terminal's committed CONTENT and the cell the
|
||||
* overlay occupies. It is deliberately NOT `left` against `buffer.x`: the helper computes
|
||||
* `left = buffer.x * cellWidth`, so those two agree by construction on the broken build
|
||||
* too, and the capture records that column as proving nothing.
|
||||
*
|
||||
* Two modelling choices, stated rather than hidden:
|
||||
* - The terminal only advances its cursor when bytes come back from the PTY. The capture
|
||||
* ran against a shell echoing at a prompt; that is modelled here as local echo.
|
||||
* - happy-dom performs no layout, so the recorded cell size (8x16 px) is supplied to the
|
||||
* render service. The pixel below is still produced by the helper, not by the test.
|
||||
* Both are validated by the harness reproducing the recorded cursorX and row text exactly.
|
||||
*/
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** Recorded on both builds: `cell: { width: 8, height: 16 }`. */
|
||||
const CELL_WIDTH_PX = 8
|
||||
const CELL_HEIGHT_PX = 16
|
||||
const PROMPT = 'KO> '
|
||||
/** Recorded `rowCells` indices for `가나다` are 4, 6, 8 — one syllable is two cells. */
|
||||
const CELLS_PER_SYLLABLE = 2
|
||||
|
||||
type ObservedEvent = { isComposing: boolean; keyCode: number | undefined; type: string }
|
||||
|
||||
/** Every event type the Windows capture listened for, so the rig is graded on all of them. */
|
||||
const RECORDED_EVENT_TYPES = [
|
||||
'beforeinput',
|
||||
'compositionend',
|
||||
'compositionstart',
|
||||
'compositionupdate',
|
||||
'input',
|
||||
'keydown',
|
||||
'keyup'
|
||||
] as const
|
||||
|
||||
type Sample = {
|
||||
cursorX: number
|
||||
overlayActive: boolean
|
||||
overlayCell: number
|
||||
overlayText: string
|
||||
rowText: string
|
||||
}
|
||||
|
||||
type EchoTerminal = {
|
||||
events: ObservedEvent[]
|
||||
ready: () => Promise<void>
|
||||
sample: () => Sample
|
||||
terminal: Terminal
|
||||
textarea: HTMLTextAreaElement
|
||||
}
|
||||
|
||||
const openTerminals: { terminal: Terminal; textarea: HTMLTextAreaElement }[] = []
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function cellSizeOf(terminal: Terminal): { height: number; width: number } {
|
||||
return (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
|
||||
}
|
||||
}
|
||||
)._core._renderService.dimensions.css.cell
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition helper is only reachable through real events on xterm's helper textarea,
|
||||
* so the terminal is driven end to end: events in, `onData` echoed back in as PTY output.
|
||||
*/
|
||||
function openEchoTerminal(): EchoTerminal {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({ cols: 80, rows: 24 })
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
const view = container.querySelector<HTMLElement>('.composition-view')
|
||||
if (!textarea || !view) {
|
||||
throw new Error('xterm did not create the helper textarea and composition view')
|
||||
}
|
||||
openTerminals.push({ terminal, textarea })
|
||||
|
||||
const events: ObservedEvent[] = []
|
||||
for (const type of RECORDED_EVENT_TYPES) {
|
||||
textarea.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
events.push({
|
||||
isComposing: (event as { isComposing?: boolean }).isComposing === true,
|
||||
keyCode: (event as KeyboardEvent).keyCode,
|
||||
type: event.type
|
||||
})
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
terminal.onData((data) => terminal.write(data))
|
||||
|
||||
const ready = async (): Promise<void> => {
|
||||
// The helper re-arms updateCompositionElements on a nested timer, and write() is async.
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
await new Promise<void>((resolve) => terminal.write('', resolve))
|
||||
}
|
||||
|
||||
const sample = (): Sample => {
|
||||
const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY)
|
||||
return {
|
||||
cursorX: terminal.buffer.active.cursorX,
|
||||
overlayActive: view.classList.contains('active'),
|
||||
overlayCell: Number.parseFloat(view.style.left || '0') / CELL_WIDTH_PX,
|
||||
overlayText: (view.textContent ?? '').replaceAll('', ''),
|
||||
rowText: line?.translateToString(true) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const cell = cellSizeOf(terminal)
|
||||
cell.width = CELL_WIDTH_PX
|
||||
cell.height = CELL_HEIGHT_PX
|
||||
return { events, ready, sample, terminal, textarea }
|
||||
}
|
||||
|
||||
function composition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: 'compositionend' | 'compositionstart' | 'compositionupdate',
|
||||
data = ''
|
||||
): void {
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
// happy-dom drops CompositionEventInit.data; Chromium supplies it.
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded `beforeinput`/`input` pair that follows every compositionupdate. xterm's
|
||||
* `_inputEvent` only acts on `insertText`, so these are inert — they are replayed because
|
||||
* the capture contains them, not to drive anything.
|
||||
*/
|
||||
function compositionInput(textarea: HTMLTextAreaElement, data: string): void {
|
||||
for (const type of ['beforeinput', 'input'] as const) {
|
||||
const event = new InputEvent(type, { bubbles: true, data, inputType: 'insertCompositionText' })
|
||||
Object.defineProperty(event, 'composed', { value: true })
|
||||
Object.defineProperty(event, 'isComposing', { value: true })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
function keyEvent(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: 'keydown' | 'keyup',
|
||||
key: string,
|
||||
keyCode: number,
|
||||
isComposing: boolean
|
||||
): void {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, key })
|
||||
Object.defineProperty(event, 'isComposing', { value: isComposing })
|
||||
Object.defineProperty(event, 'keyCode', { value: keyCode })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function setValue(textarea: HTMLTextAreaElement, value: string): void {
|
||||
textarea.value = value
|
||||
textarea.setSelectionRange(value.length, value.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded 가나다라 run, key by key, from `ko164.json.events`. `commit` is the syllable
|
||||
* MS Korean resolves when the next vowel arrives and the trailing consonant moves on — the
|
||||
* boundary where Chromium fires compositionend and compositionstart in one task.
|
||||
*/
|
||||
const KOREAN_2SET = [
|
||||
{ key: 'r', keyCode: 82, preedit: 'ㄱ' },
|
||||
{ key: 'k', keyCode: 75, preedit: '가' },
|
||||
{ key: 's', keyCode: 83, preedit: '간' },
|
||||
{ key: 'k', keyCode: 75, commit: '가', preedit: '나' },
|
||||
{ key: 'e', keyCode: 69, preedit: '낟' },
|
||||
{ key: 'k', keyCode: 75, commit: '나', preedit: '다' },
|
||||
{ key: 'f', keyCode: 70, preedit: '달' },
|
||||
{ key: 'k', keyCode: 75, commit: '다', preedit: '라' }
|
||||
] as const
|
||||
|
||||
const ORDINARY_KEYS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const
|
||||
|
||||
async function typeKorean(rig: EchoTerminal): Promise<{ committed: string; sample: Sample }[]> {
|
||||
const samples: { committed: string; sample: Sample }[] = []
|
||||
let committed = ''
|
||||
let composing = false
|
||||
|
||||
for (const stroke of KOREAN_2SET) {
|
||||
// Recorded: the first Process keydown reports isComposing false, every later one true.
|
||||
keyEvent(rig.textarea, 'keydown', 'Process', 229, composing)
|
||||
if ('commit' in stroke) {
|
||||
// The IME resolves the syllable into the textarea, ends, and reopens in one task.
|
||||
setValue(rig.textarea, committed + stroke.commit)
|
||||
composition(rig.textarea, 'compositionupdate', stroke.commit)
|
||||
compositionInput(rig.textarea, stroke.commit)
|
||||
composition(rig.textarea, 'compositionend', stroke.commit)
|
||||
committed += stroke.commit
|
||||
composition(rig.textarea, 'compositionstart')
|
||||
} else if (!composing) {
|
||||
composition(rig.textarea, 'compositionstart')
|
||||
composing = true
|
||||
}
|
||||
setValue(rig.textarea, committed + stroke.preedit)
|
||||
composition(rig.textarea, 'compositionupdate', stroke.preedit)
|
||||
compositionInput(rig.textarea, stroke.preedit)
|
||||
keyEvent(rig.textarea, 'keyup', 'Process', 229, true)
|
||||
keyEvent(rig.textarea, 'keyup', stroke.key, stroke.keyCode, true)
|
||||
await rig.ready()
|
||||
samples.push({ committed, sample: rig.sample() })
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
describe('Korean preedit overlay against committed terminal content', () => {
|
||||
beforeEach(() => {
|
||||
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// updateCompositionElements re-arms itself on a timer; end the composition and let the
|
||||
// pending one run before the render service it reads is disposed.
|
||||
for (const { textarea } of openTerminals) {
|
||||
composition(textarea, 'compositionend')
|
||||
}
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
while (openTerminals.length > 0) {
|
||||
openTerminals.pop()?.terminal.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('paints each preedit past every syllable the IME has already committed', async () => {
|
||||
const rig = openEchoTerminal()
|
||||
await new Promise<void>((resolve) => rig.terminal.write(PROMPT, resolve))
|
||||
expect(rig.sample()).toMatchObject({ cursorX: PROMPT.length, rowText: PROMPT })
|
||||
|
||||
const samples = await typeKorean(rig)
|
||||
|
||||
// Reported diagnostic, in the columns the capture recorded.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'[overlap] per-key cursorX/row/overlay:',
|
||||
JSON.stringify(
|
||||
samples.map(({ committed, sample }) => ({
|
||||
committed,
|
||||
cursorX: sample.cursorX,
|
||||
overlayCell: sample.overlayCell,
|
||||
overlayText: sample.overlayText,
|
||||
rowText: sample.rowText
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
// The cell each preedit is painted on, against the first cell no committed syllable
|
||||
// has a claim on. The right-hand side is counted from the compositionend events the
|
||||
// IME fired, so it does not come from the buffer the left-hand side is derived from.
|
||||
expect(
|
||||
samples.map(({ sample }) => ({
|
||||
overlayCell: sample.overlayCell,
|
||||
preedit: sample.overlayText
|
||||
}))
|
||||
).toEqual(
|
||||
samples.map(({ committed, sample }) => ({
|
||||
overlayCell: PROMPT.length + committed.length * CELLS_PER_SYLLABLE,
|
||||
preedit: sample.overlayText
|
||||
}))
|
||||
)
|
||||
|
||||
for (const { committed, sample } of samples) {
|
||||
// Every committed syllable is on screen, so nothing is queued to land under the overlay.
|
||||
expect(sample.rowText).toBe(PROMPT + committed)
|
||||
expect(sample.cursorX).toBe(PROMPT.length + committed.length * CELLS_PER_SYLLABLE)
|
||||
expect(sample.overlayActive).toBe(true)
|
||||
}
|
||||
|
||||
// The recorded v1.4.176 run, column for column. v1.4.164 recorded cursorX
|
||||
// 4,4,4,4,4,6,6,8 and `KO> 가나` under a composing 라 — one syllable behind throughout.
|
||||
expect(samples.map(({ sample }) => sample.cursorX)).toEqual([4, 4, 4, 6, 6, 8, 8, 10])
|
||||
expect(samples.map(({ sample }) => sample.rowText)).toEqual([
|
||||
'KO> ',
|
||||
'KO> ',
|
||||
'KO> ',
|
||||
'KO> 가',
|
||||
'KO> 가',
|
||||
'KO> 가나',
|
||||
'KO> 가나',
|
||||
'KO> 가나다'
|
||||
])
|
||||
expect(samples.at(-1)?.sample).toMatchObject({ overlayCell: 10, overlayText: '라' })
|
||||
})
|
||||
|
||||
it('ordinary Latin typing travels no composition path and never lags the cursor', async () => {
|
||||
const rig = openEchoTerminal()
|
||||
await new Promise<void>((resolve) => rig.terminal.write(PROMPT, resolve))
|
||||
|
||||
const samples: Sample[] = []
|
||||
for (const character of ORDINARY_KEYS) {
|
||||
const keyCode = character.toUpperCase().charCodeAt(0)
|
||||
// Recorded en176: a bare keydown/keyup pair per key, and nothing else.
|
||||
keyEvent(rig.textarea, 'keydown', character, keyCode, false)
|
||||
keyEvent(rig.textarea, 'keyup', character, keyCode, false)
|
||||
await rig.ready()
|
||||
samples.push(rig.sample())
|
||||
}
|
||||
|
||||
// Self-certification: this arm must not be a Latin *preedit*. Counts are the recorded
|
||||
// en176 stats { starts: 0, ends: 0, updates: 0, kc229: 0, keydowns: 8 } and its 16 events.
|
||||
expect(rig.events.filter((event) => event.type.startsWith('composition'))).toEqual([])
|
||||
expect(rig.events.filter((event) => event.type.endsWith('input'))).toEqual([])
|
||||
expect(rig.events.filter((event) => event.isComposing)).toEqual([])
|
||||
expect(rig.events.filter((event) => event.keyCode === 229)).toEqual([])
|
||||
expect(rig.events).toHaveLength(ORDINARY_KEYS.length * 2)
|
||||
|
||||
for (const [index, sample] of samples.entries()) {
|
||||
const typed = ORDINARY_KEYS.slice(0, index + 1).join('')
|
||||
expect(sample.rowText).toBe(PROMPT + typed)
|
||||
expect(sample.cursorX).toBe(PROMPT.length + typed.length)
|
||||
expect(sample.overlayActive).toBe(false)
|
||||
}
|
||||
// Recorded en176 end state.
|
||||
expect(samples.at(-1)).toMatchObject({ cursorX: 12, rowText: 'KO> abcdefgh' })
|
||||
})
|
||||
|
||||
it('the Korean arm does travel the composition path the Latin arm does not', async () => {
|
||||
const rig = openEchoTerminal()
|
||||
await new Promise<void>((resolve) => rig.terminal.write(PROMPT, resolve))
|
||||
await typeKorean(rig)
|
||||
|
||||
// Recorded ko164/ko176 stats: starts 4, ends 3, updates 11; ko164 keydowns 8, kc229 16
|
||||
// (a Process keydown and a Process keyup per key), and 64 events in total.
|
||||
const countOf = (type: string): number => rig.events.filter((e) => e.type === type).length
|
||||
expect(countOf('compositionstart')).toBe(4)
|
||||
expect(countOf('compositionend')).toBe(3)
|
||||
expect(countOf('compositionupdate')).toBe(11)
|
||||
expect(countOf('keydown')).toBe(8)
|
||||
expect(rig.events.filter((event) => event.keyCode === 229)).toHaveLength(16)
|
||||
expect(rig.events).toHaveLength(64)
|
||||
// The paired half of the Latin arm's self-certification: isComposing is not inert here.
|
||||
expect(rig.events.filter((event) => event.isComposing).length).toBeGreaterThan(0)
|
||||
|
||||
// The recorded per-key ordering, from `ko164.json.events`.
|
||||
expect(rig.events.map((event) => event.type).slice(0, 7)).toEqual([
|
||||
'keydown',
|
||||
'compositionstart',
|
||||
'compositionupdate',
|
||||
'beforeinput',
|
||||
'input',
|
||||
'keyup',
|
||||
'keyup'
|
||||
])
|
||||
// Syllable boundary: the previous syllable ends and the next opens in the same task.
|
||||
expect(rig.events.map((event) => event.type).slice(19, 30)).toEqual([
|
||||
'keydown',
|
||||
'compositionupdate',
|
||||
'beforeinput',
|
||||
'input',
|
||||
'compositionend',
|
||||
'compositionstart',
|
||||
'compositionupdate',
|
||||
'beforeinput',
|
||||
'input',
|
||||
'keyup',
|
||||
'keyup'
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user