// C12 — IME composition Enter handling unit test. // // Replays the keydown handler defined in static/app.js against // synthetic keyboard events to verify: // 1. Plain Enter → SEND // 2. Shift+Enter → NO SEND (newline) // 3. Enter during IME composition (compositionstart fired, no compositionend yet) // → NO SEND // 4. Enter on the same tick as compositionend → NO SEND (setTimeout defers flag flip) // 5. Enter after compositionend tick has elapsed → SEND // // Source under test is read from static/app.js so it cannot drift from the // real production handler. import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { strict as assert } from "node:assert"; const __dirname = dirname(fileURLToPath(import.meta.url)); const APP_JS = resolve(__dirname, "..", "..", "static", "app.js"); const src = readFileSync(APP_JS, "utf-8"); // Sanity: the production handler still contains the three guards. assert.match( src, /input\.addEventListener\("compositionstart"/, "compositionstart listener missing in app.js", ); assert.match( src, /input\.addEventListener\("compositionend"/, "compositionend listener missing in app.js", ); assert.match( src, /if \(ev\.key !== "Enter"\) return;/, "Enter guard missing in app.js", ); assert.match(src, /if \(ev\.shiftKey\) return;/, "Shift guard missing in app.js"); assert.match( src, /if \(input\._composing\) return;/, "_composing guard missing in app.js", ); // Replicate the exact handler shape from app.js so we can fire synthetic events. // (The above asserts guarantee the production code keeps the same guards.) let sendCalls = []; function makeInput() { const listeners = {}; const input = { _composing: false, value: "", addEventListener(name, fn) { (listeners[name] ||= []).push(fn); }, dispatch(name, ev) { for (const fn of listeners[name] || []) fn(ev); }, }; // == Mirror of static/app.js IME handlers (verified by regex above) == input.addEventListener("compositionstart", () => { input._composing = true; }); input.addEventListener("compositionend", () => { setTimeout(() => { input._composing = false; }, 0); }); input.addEventListener("keydown", (ev) => { if (ev.key !== "Enter") return; if (ev.shiftKey) return; if (input._composing) return; ev.preventDefault(); sendCalls.push(ev.target.value); }); // == end mirror == return input; } function ev(key, opts = {}) { return { key, shiftKey: !!opts.shift, ctrlKey: !!opts.ctrl, metaKey: !!opts.meta, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, target: { value: opts.value || "" }, }; } function reset(input) { sendCalls = []; input._composing = false; } const tick = () => new Promise((r) => setTimeout(r, 5)); const results = []; async function check(name, fn) { try { await fn(); results.push({ name, ok: true }); console.log(` ✓ ${name}`); } catch (e) { results.push({ name, ok: false, err: e.message }); console.log(` ✗ ${name}: ${e.message}`); } } const input = makeInput(); await check("plain Enter → send", () => { reset(input); const e = ev("Enter", { value: "hello" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 1); assert.equal(sendCalls[0], "hello"); assert.equal(e.defaultPrevented, true); }); await check("Shift+Enter → no send (newline)", () => { reset(input); const e = ev("Enter", { shift: true, value: "hello\n" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 0); assert.equal(e.defaultPrevented, false); }); await check("Enter during IME composition → no send", () => { reset(input); input.dispatch("compositionstart", {}); const e = ev("Enter", { value: "한" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 0); assert.equal(e.defaultPrevented, false); }); await check("Enter on compositionend tick → no send (deferred flag)", async () => { reset(input); input.dispatch("compositionstart", {}); input.dispatch("compositionend", {}); // compositionend dispatches; the setTimeout flag flip is pending. // The synthetic Enter that ends composition on Chrome/Safari fires NOW. const e = ev("Enter", { value: "한글" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 0, "compositionend tick must not send"); assert.equal(e.defaultPrevented, false); }); await check("Enter after composition tick → send", async () => { reset(input); input.dispatch("compositionstart", {}); input.dispatch("compositionend", {}); await tick(); const e = ev("Enter", { value: "한글 입력" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 1); assert.equal(sendCalls[0], "한글 입력"); assert.equal(e.defaultPrevented, true); }); await check("Cmd+Enter still sends (backwards compat)", () => { reset(input); const e = ev("Enter", { meta: true, value: "hi" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 1); assert.equal(sendCalls[0], "hi"); }); await check("non-Enter key → no send", () => { reset(input); const e = ev("a", { value: "hi" }); input.dispatch("keydown", e); assert.equal(sendCalls.length, 0); assert.equal(e.defaultPrevented, false); }); const total = results.length; const failed = results.filter((r) => !r.ok).length; const passed = total - failed; console.log(`\nC12 IME: ${passed}/${total} passed`); process.exit(failed === 0 ? 0 : 1);