var assert = require("node:assert/strict"); var fs = require("node:fs"); var path = require("node:path"); var test = require("node:test"); var pathToFileURL = require("node:url").pathToFileURL; function FakeElement(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = {}; this.listeners = {}; this.className = ""; this.textContent = ""; this.value = ""; this.disabled = false; this.hidden = false; this.isConnected = true; this.focusCalls = []; var element = this; this.classList = { add: function () { for (var i = 0; i < arguments.length; i++) if ((" " + element.className + " ").indexOf(" " + arguments[i] + " ") === -1) element.className += (element.className ? " " : "") + arguments[i]; }, }; Object.defineProperty(this, "innerHTML", { get: function () { return ""; }, set: function () { element.children = []; }, }); } FakeElement.prototype.appendChild = function (child) { this.children.push(child); child.parentNode = this; return child; }; FakeElement.prototype.setAttribute = function (name, value) { this.attributes[name] = String(value); }; FakeElement.prototype.addEventListener = function (name, handler) { this.listeners[name] = handler; }; FakeElement.prototype.click = function () { if (this.listeners.click) this.listeners.click({ currentTarget: this }); }; FakeElement.prototype.focus = function (options) { this.focusCalls.push(options); }; function flatten(root) { var result = [root]; for (var i = 0; i < root.children.length; i++) result = result.concat(flatten(root.children[i])); return result; } function findText(root, text) { return flatten(root).find(function (node) { return node.textContent === text; }); } function fixtureDocument() { var elements = { "home-debate-controls-slot": new FakeElement("div"), "home-mate-chat-composer": new FakeElement("div"), "home-mate-chat-session-model": new FakeElement("div"), }; var ordinaryInput = new FakeElement("textarea"); var ordinarySend = new FakeElement("button"); elements["home-mate-chat-composer"].appendChild(ordinaryInput); elements["home-mate-chat-composer"].appendChild(ordinarySend); return { elements: elements, ordinaryInput: ordinaryInput, ordinarySend: ordinarySend, document: { createElement: function (tag) { return new FakeElement(tag); }, getElementById: function (id) { return elements[id] || null; }, }, }; } function liveMessages(overrides) { var header = Object.assign({ role: "debate_header", phase: "live", topic: "Housing", round: 2, handRaised: false, interaction: null }, overrides || {}); return [header, { role: "debate_turn", turnId: "turn-2", status: "active", mateName: "Panel", speakerRole: "Analyst", round: 2, text: "Opening", activity: "Speaking" }]; } test("live Home debate replaces the ordinary composer and routes exact default controls", async function () { var originalDocument = global.document; var originalFrame = global.requestAnimationFrame; var fixture = fixtureDocument(); var sent = []; global.document = fixture.document; global.requestAnimationFrame = function (callback) { callback(); return 1; }; try { var storeModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/store.js")).href); var wsModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/ws-ref.js")).href); var controls = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/home-debate-controls.js")).href); storeModule.createStore({ homeChatMateId: "builtin:clay", homeChatSessionId: "cli-live" }); wsModule.setWs({ readyState: 1, send: function (data) { sent.push(JSON.parse(data)); } }); assert.equal(controls.renderHomeDebateControls([], "request-live"), false); assert.equal(fixture.elements["home-mate-chat-composer"].hidden, false); assert.equal(fixture.elements["home-mate-chat-session-model"].hidden, false); assert.equal(controls.renderHomeDebateControls(liveMessages(), "request-live"), true); var slot = fixture.elements["home-debate-controls-slot"]; assert.equal(fixture.elements["home-mate-chat-composer"].hidden, true); assert.equal(fixture.ordinaryInput.parentNode.hidden, true); assert.equal(fixture.ordinarySend.parentNode.hidden, true); assert.equal(fixture.elements["home-mate-chat-session-model"].hidden, true); assert.equal(slot.hidden, false); assert.equal(slot.children[0].attributes.role, "toolbar"); assert.equal(slot.children[0].attributes["aria-label"], "Live debate controls"); assert.equal(findText(slot, "Panel is speaking").attributes["aria-live"], "polite"); assert.equal(flatten(slot).filter(function (node) { return node.tagName === "I"; }).length, 3); var raise = findText(slot, "Raise hand"); var stop = findText(slot, "Stop debate"); raise.click(); stop.click(); assert.deepEqual(sent, [ { type: "home_debate_control", action: "hand_raise", mateId: "builtin:clay", sessionId: "cli-live", requestId: "request-live", text: "", response: null }, { type: "home_debate_control", action: "stop", mateId: "builtin:clay", sessionId: "cli-live", requestId: "request-live", text: "", response: null }, ]); controls.renderHomeDebateControls(liveMessages({ stopping: true }), "request-live"); assert.equal(findText(slot, "Stopping after Panel finishes").attributes["aria-live"], "polite"); var cancel = findText(slot, "Cancel stop"); assert.match(cancel.className, /home-debate-control-secondary/); cancel.click(); assert.equal(sent[2].action, "cancel_stop"); assert.equal(findText(slot, "Cancelling…").disabled, true); controls.renderHomeDebateControls(liveMessages({ stopping: false }), "request-live"); assert.ok(findText(slot, "Stop debate")); controls.renderHomeDebateControls(liveMessages({ handRaised: true }), "request-live"); assert.equal(findText(slot, "Hand raised").disabled, true); assert.equal(findText(slot, "Hand raised").attributes["aria-pressed"], "true"); } finally { global.document = originalDocument; global.requestAnimationFrame = originalFrame; } }); test("user-floor and conclude controls preserve IME, caret, focus, and occupy one stable slot", async function () { var originalDocument = global.document; var originalFrame = global.requestAnimationFrame; var fixture = fixtureDocument(); var sent = []; global.document = fixture.document; global.requestAnimationFrame = function (callback) { callback(); return 1; }; try { var storeModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/store.js")).href); var wsModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/ws-ref.js")).href); var controls = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/home-debate-controls.js")).href); storeModule.store.set({ homeChatMateId: "builtin:clay", homeChatSessionId: "local:31" }); wsModule.setWs({ readyState: 1, send: function (data) { sent.push(JSON.parse(data)); } }); var floorState = liveMessages({ interaction: "user_floor" }); controls.renderHomeDebateControls(floorState, "floor-request"); var slot = fixture.elements["home-debate-controls-slot"]; var input = flatten(slot).find(function (node) { return node.tagName === "TEXTAREA"; }); assert.deepEqual(input.focusCalls, [{ preventScroll: true }]); input.value = "한글 의견"; input.selectionStart = 3; input.selectionEnd = 3; input.listeners.input(); input.listeners.compositionstart(); var prevented = 0; input.listeners.keydown({ key: "Enter", shiftKey: false, isComposing: false, keyCode: 229, preventDefault: function () { prevented++; } }); assert.equal(sent.length, 0); assert.equal(prevented, 0); controls.renderHomeDebateControls(floorState, "floor-request"); var stableInput = flatten(slot).find(function (node) { return node.tagName === "TEXTAREA"; }); assert.equal(stableInput, input); assert.equal(stableInput.value, "한글 의견"); assert.equal(stableInput.selectionStart, 3); assert.equal(stableInput.focusCalls.length, 1); input.listeners.compositionend(); input.listeners.keydown({ key: "Enter", shiftKey: false, isComposing: false, keyCode: 13, preventDefault: function () { prevented++; } }); assert.equal(prevented, 1); assert.equal(sent[0].action, "user_floor"); assert.equal(sent[0].text, "한글 의견"); assert.equal(findText(slot, "Sending…").disabled, true); controls.renderHomeDebateControls(liveMessages(), "conclude-request"); controls.renderHomeDebateControls(liveMessages({ interaction: "conclude" }), "conclude-request"); var concludeInput = flatten(slot).find(function (node) { return node.tagName === "TEXTAREA"; }); concludeInput.value = "Address implementation risk"; findText(slot, "Continue").click(); assert.equal(sent[1].action, "conclude"); assert.equal(sent[1].response, "continue"); assert.equal(sent[1].text, "Address implementation risk"); controls.renderHomeDebateControls(liveMessages(), "end-request"); controls.renderHomeDebateControls(liveMessages({ interaction: "conclude" }), "end-request"); findText(slot, "End debate").click(); assert.equal(sent[2].action, "conclude"); assert.equal(sent[2].response, "end"); } finally { global.document = originalDocument; global.requestAnimationFrame = originalFrame; } }); test("terminal Home debate remains special and offers resume plus fresh debate actions", async function () { var originalDocument = global.document; var fixture = fixtureDocument(); var starts = 0; var sent = []; global.document = fixture.document; try { var storeModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/store.js")).href); var wsModule = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/ws-ref.js")).href); var controls = await import(pathToFileURL(path.join(__dirname, "../lib/public/modules/home-debate-controls.js")).href); storeModule.store.set({ homeChatMateId: "builtin:clay", homeChatSessionId: "cli-ended" }); wsModule.setWs({ readyState: 1, send: function (data) { sent.push(JSON.parse(data)); } }); controls.renderHomeDebateControls([{ role: "debate_header", phase: "interrupted", reason: "interrupted" }], "terminal", function () { starts++; }); var slot = fixture.elements["home-debate-controls-slot"]; assert.equal(fixture.elements["home-mate-chat-composer"].hidden, true); assert.equal(fixture.elements["home-mate-chat-session-model"].hidden, true); assert.equal(flatten(slot).some(function (node) { return node.tagName === "TEXTAREA"; }), false); assert.equal(findText(slot, "Debate interrupted when Clay restarted").attributes.role, "status"); findText(slot, "Resume debate").click(); assert.deepEqual(sent[0], { type: "home_debate_control", action: "resume", mateId: "builtin:clay", sessionId: "cli-ended", requestId: "terminal", text: "", response: null }); assert.equal(findText(slot, "Resuming…").disabled, true); controls.renderHomeDebateControls([{ role: "debate_header", phase: "ended", reason: "natural" }], "terminal-new", function () { starts++; }); findText(slot, "New debate").click(); assert.equal(starts, 1); controls.renderHomeDebateControls([], "normal"); assert.equal(fixture.elements["home-mate-chat-composer"].hidden, false); assert.equal(fixture.elements["home-mate-chat-session-model"].hidden, false); assert.equal(slot.hidden, true); } finally { global.document = originalDocument; } }); test("project chat restores debate live controls without a Home composer", function () { var root = path.join(__dirname, ".."); var index = fs.readFileSync(path.join(root, "lib/public/index.html"), "utf8"); var workspace = fs.readFileSync(path.join(root, "lib/public/modules/project-debate-workspace.js"), "utf8"); var live = fs.readFileSync(path.join(root, "lib/public/modules/home-debate-live.js"), "utf8"); var css = fs.readFileSync(path.join(root, "lib/public/css/home-debate-live.css"), "utf8"); assert.doesNotMatch(index, /id="home-mate-chat-composer"|id="home-debate-controls-slot"/); assert.match(workspace, /home-debate-live\.js/); assert.match(workspace, /home-debate-planning\.js/); assert.match(workspace, /restore/); assert.doesNotMatch(live, /home-debate-live-controls|home-debate-live-interaction|Raise hand|End debate/); assert.match(css, /\.home-debate-control-surface[\s\S]*var\(--bg\)[\s\S]*var\(--border-subtle\)/); assert.match(css, /@media \(max-width: 600px\)[\s\S]*var\(--safe-bottom/); assert.match(css, /home-debate-control-terminal \.home-debate-control-actions[\s\S]*margin-left:\s*auto/); assert.match(css, /prefers-reduced-motion: reduce[\s\S]*home-debate-control-dots/); assert.doesNotMatch(css, /#[0-9a-f]{3,8}|serif|linear-gradient/i); });