const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const vm = require('node:vm') const ts = require('typescript') const React = require('react') const { renderToStaticMarkup } = require('react-dom/server') function load(file, overrides = {}, globals = {}) { const exports = {} const source = fs.readFileSync(require.resolve(file), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals }) return exports } const reveal = load('../src/renderer/src/components/agentResponseReveal.ts') const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.' const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.` test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => { const document = createAgentRevealDocument(content) const first = agentRevealFrame(document, 1000, 1000) assert.equal(first.content, paragraph) assert.equal(first.complete, false) const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL) assert.match(second.content, /1\. 물리적/) assert.match(second.content, /앞바퀴로 조향합니다\./) assert.doesNotMatch(second.content, /2\. 제어/) const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2) assert.match(third.content, /2\. 제어 설정을 확인합니다\./) assert.doesNotMatch(third.content, /마지막 설명/) }) test('keeps fenced code and GFM tables intact', () => { const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |' const document = createAgentRevealDocument(text) assert.equal(document.boundaries.length, 3) const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content assert.equal((code.match(/```/g) ?? []).length, 2) assert.match(code, /print\("종료"\)/) assert.doesNotMatch(code, /\| 항목/) assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text) }) test('reference link definitions are available before their position in the answer is revealed', () => { const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide') const first = agentRevealFrame(document, 0, 0) assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/) assert.doesNotMatch(first.content, /다음 문단/) }) test('history, elapsed answers and reduced-motion users see the complete response immediately', () => { const document = createAgentRevealDocument(content) for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) { assert.equal(frame.content, content) assert.equal(frame.complete, true) assert.equal(frame.fadeFrom, undefined) } }) test('long answers are grouped into at most twenty steps and finish within five seconds', () => { const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n') const document = createAgentRevealDocument(text) assert.ok(document.boundaries.length <= 20) assert.ok(agentRevealFrame(document, 0, 0).duration < 5000) assert.equal(agentRevealFrame(document, 0, 5000).content, text) }) test('only the newly appended paragraph gets the fade class', () => { const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {} }) const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.') const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true })) assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1) assert.match(html, /첫 번째 문단/) assert.match(html, /두 번째 문단/) assert.doesNotMatch(html, /세 번째 문단/) }) function renderResponse(message, now, reducedMotion = false, extra = {}) { const MarkdownMessage = ({ content }) => React.createElement('p', null, content) const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', { './ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal, react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} } }, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } }) return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })) } test('internal cumulative memory is not rendered in the response bubble', () => { const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0) assert.match(html, /화면 설명/) assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/) }) test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => { const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0) assert.ok(html.includes('A-Copilot')) assert.ok(!html.includes('A_COPILOT')) assert.ok(!html.includes('A-Copilot · A-Copilot')) assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1) assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max')) }) test('approval controls wait for the explanation; restored history does not replay', () => { const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' } const first = renderResponse(message, 1000) assert.match(first, /조향 설명/) assert.match(first, /aria-busy="true"/) assert.doesNotMatch(first, /승인|QWEN/) for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) { assert.match(html, /aria-busy="false"/) assert.match(html, /마지막 설명/) assert.match(html, /승인/) assert.match(html, /QWEN/) } }) test('reveal timing is not included in conversation persistence API payloads', () => { const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) let expression function visit(node) { if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source) ts.forEachChild(node, visit) } visit(source) assert.ok(expression) const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }] const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 })) assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false) assert.equal(payload.messages[0].content, '전체 답변') }) test('animation honors reduced motion in CSS', () => { const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8') assert.match(css, /@keyframes agent-response-fade/) assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/) }) function elements(root, predicate) { if (!React.isValidElement(root)) return [] return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] } test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => { const MarkdownMessage = () => null const ExpandableMarkdownMessage = () => null let now = 1000 const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', { './ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal, react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} } }, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } }) const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } } const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }) assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0) now = 10_000 const fresh = render() assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false) assert.equal(elements(fresh, node => node.type === 'button').length, 1) const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 }) const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0] assert.equal(widget.props.defaultCollapsed, true) assert.equal(widget.props.collapseOnSignal, true) assert.equal(widget.props.collapseSignal, 4) assert.equal(elements(history, node => node.type === 'button').length, 1) }) for (const dark of [false, true]) { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => { let expanded const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}, react: { ...React, useEffect() {}, useState(initial) { if (expanded === undefined) expanded = initial return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] } } }) const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50) const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true }) let tree = render() let button = elements(tree, node => node.type === 'button')[0] assert.equal(button.props['aria-label'], '자세히 보기') assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length) button.props.onClick() tree = render() button = elements(tree, node => node.type === 'button')[0] assert.equal(button.props['aria-label'], '접기') assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content) button.props.onClick() assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기') }) } test('short answers do not show an unnecessary collapse button', () => { const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}, react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] } }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) assert.equal(elements(tree, node => node.type === 'button').length, 0) }) for (const dark of [false, true]) { test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => { const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, { dark, footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy') }) assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p>