242 lines
14 KiB
JavaScript
242 lines
14 KiB
JavaScript
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': {}
|
|
})
|
|
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('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': {},
|
|
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': {},
|
|
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"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
|
|
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
|
|
})
|
|
}
|
|
|
|
test('copy footer remains available when the message has no model metadata', () => {
|
|
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
|
|
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
|
|
})
|
|
assert.match(html, /min-h-6/)
|
|
assert.match(html, /title="복사"/)
|
|
})
|
|
|
|
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
|
|
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
|
|
let copied = false
|
|
let clipboardText
|
|
const { MessageCopyButton } = compileCopy(declaration.getText(source))
|
|
function compileCopy(text) {
|
|
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
|
} }).outputText
|
|
const exports = {}
|
|
vm.runInNewContext(code, {
|
|
exports, require,
|
|
useState: () => [copied, value => { copied = value }], t: key => key,
|
|
Check: () => null, Copy: () => null,
|
|
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
|
|
window: { setTimeout() {} }
|
|
})
|
|
return exports
|
|
}
|
|
const content = '# 요약\n\n전체 답변입니다.'
|
|
const button = MessageCopyButton({ content, dark: true })
|
|
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
|
|
assert.match(button.props.className, /shrink-0 cursor-pointer/)
|
|
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
|
|
button.props.onClick()
|
|
await Promise.resolve()
|
|
assert.equal(clipboardText, content)
|
|
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
|
|
})
|