diff --git a/config/scripts/record-ime-trace.mjs b/config/scripts/record-ime-trace.mjs index 32a6fec66f7..a4ed1da82b2 100644 --- a/config/scripts/record-ime-trace.mjs +++ b/config/scripts/record-ime-trace.mjs @@ -100,6 +100,7 @@ let initial = snapshot() function snapshot() { return { + selectionDirection: target.selectionDirection ?? 'none', selectionEnd: target.selectionEnd ?? 0, selectionStart: target.selectionStart ?? 0, value: target.value @@ -114,20 +115,24 @@ function record(event) { } else if (event instanceof InputEvent) { entry.data = event.data ?? null entry.inputType = event.inputType - // Safari reports undefined rather than false and the distinction is load-bearing, - // so it is preserved instead of coerced. - entry.isComposing = event.isComposing + // Safari reports undefined rather than false and the distinction is load-bearing. + // JSON.stringify drops an undefined value outright, so null carries it instead; + // transcribe it back to undefined in the fixture. + entry.isComposing = event.isComposing ?? null } else if (event instanceof KeyboardEvent) { entry.code = event.code entry.isComposing = event.isComposing entry.key = event.key entry.keyCode = event.keyCode + // The accent panel a macOS press-and-hold opens is only recognisable by this. + entry.repeat = event.repeat } events.push(entry) countEl.textContent = String(events.length) const detail = [ entry.key ? 'key=' + entry.key : '', entry.keyCode === undefined ? '' : 'keyCode=' + entry.keyCode, + entry.repeat ? 'repeat' : '', entry.inputType ? entry.inputType : '', entry.data === undefined ? '' : 'data=' + JSON.stringify(entry.data), 'isComposing=' + entry.isComposing, diff --git a/config/scripts/record-ime-trace.test.mjs b/config/scripts/record-ime-trace.test.mjs new file mode 100644 index 00000000000..bf22be6650a --- /dev/null +++ b/config/scripts/record-ime-trace.test.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +// The recorder writes traces that the fixture schema has to be able to hold. The two +// live in different languages and different build graphs, so nothing but this checks +// that a field one of them learns about ever reaches the other. + +const scriptsDir = import.meta.dirname +const projectDir = path.resolve(scriptsDir, '../..') + +const recorderSource = readFileSync(path.join(scriptsDir, 'record-ime-trace.mjs'), 'utf8') +const schemaSource = readFileSync( + path.join(projectDir, 'src/renderer/src/lib/ime-composition-trace.test-fixtures.ts'), + 'utf8' +) + +function declaredSchemaFields(typeName) { + const body = schemaSource.match(new RegExp(`export type ${typeName} = [^{]*\\{([^}]*)\\}`))?.[1] + if (!body) { + throw new Error(`${typeName} not found in the fixture schema`) + } + return new Set(Array.from(body.matchAll(/^\s{2}(\w+)\??:/gm), (match) => match[1])) +} + +describe('record-ime-trace', () => { + it('captures nothing the trace schema cannot express', () => { + const captured = new Set(Array.from(recorderSource.matchAll(/entry\.(\w+) =/g), (m) => m[1])) + const expressible = new Set([ + ...declaredSchemaFields('ImeTraceKeyEvent'), + ...declaredSchemaFields('ImeTraceInputEvent'), + ...declaredSchemaFields('ImeTraceCompositionEvent') + ]) + + expect(captured.size).toBeGreaterThan(0) + expect([...captured].filter((field) => !expressible.has(field))).toEqual([]) + }) + + it('records the fields the platform quirks are recognised by', () => { + // repeat distinguishes a held key from the first press, which is the only signal + // a macOS press-and-hold accent panel produces; selectionDirection is the only way + // a backward preedit range survives as anything but a caret. + expect(recorderSource).toMatch(/entry\.repeat = event\.repeat/) + expect(recorderSource).toMatch(/selectionDirection: target\.selectionDirection/) + expect(declaredSchemaFields('ImeTraceKeyEvent')).toContain('repeat') + expect(declaredSchemaFields('ImeTraceTargetState')).toContain('selectionDirection') + }) + + it('carries an undefined isComposing as null rather than dropping the key', () => { + // JSON.stringify omits undefined-valued keys outright, so coercing here would + // erase the one bit a Safari trace is recorded to preserve. + expect(recorderSource).toMatch(/entry\.isComposing = event\.isComposing \?\? null/) + expect(JSON.stringify({ isComposing: undefined })).toBe('{}') + }) +})