148 lines
7.3 KiB
JavaScript
148 lines
7.3 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 compile(source, overrides = {}, globals = {}) {
|
|
const exports = {}
|
|
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 widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
|
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
|
|
})
|
|
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
|
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
|
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
|
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
|
t: key => key, useLocaleRender() {},
|
|
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
|
ChatEmptyState: widgets.ChatEmptyState,
|
|
ProgressiveChatResponse,
|
|
ChatImageAttachments,
|
|
MessageCopyButton: () => null,
|
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
|
})
|
|
|
|
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
|
const tree = messages.AgentMessages({
|
|
dark: true, enabled: true, statusLoading: false, pending: false,
|
|
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
|
|
})
|
|
let row
|
|
function visit(element) {
|
|
if (!React.isValidElement(element)) return
|
|
const children = React.Children.toArray(element.props.children)
|
|
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
|
|
children.forEach(visit)
|
|
}
|
|
visit(tree)
|
|
assert.ok(row)
|
|
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
|
|
assert.match(row[0].props.className, /whitespace-pre-wrap/)
|
|
assert.equal(row[1].type, ChatImageAttachments)
|
|
assert.equal(row[1].props.variant, 'sent')
|
|
assert.equal(row[1].props.images.length, 1)
|
|
})
|
|
|
|
for (const dark of [false, true]) {
|
|
for (const mode of ['AiMessages', 'AgentMessages']) {
|
|
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
|
|
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
|
|
assert.match(html, /role="status"/)
|
|
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
|
|
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
|
|
assert.doesNotMatch(html, /animate-spin/)
|
|
assert.match(html, /motion-reduce:animate-none/)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
|
|
const html = renderToStaticMarkup(messages.AiMessages({
|
|
dark: true, pending: false, collapseSignal: 2,
|
|
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
|
|
}))
|
|
assert.match(html, /data-reveal-start="1234"/)
|
|
assert.match(html, /data-collapsed="false"/)
|
|
assert.match(html, /전체 답변/)
|
|
})
|
|
|
|
for (const dark of [false, true]) {
|
|
for (const [mode, expected] of [
|
|
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
|
|
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
|
|
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
|
|
]) {
|
|
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
|
|
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
|
|
assert.match(html, expected)
|
|
assert.match(html, /rounded-lg border px-8 py-5/)
|
|
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
|
|
assert.doesNotMatch(html, /<button|role="status"/)
|
|
})
|
|
}
|
|
}
|
|
|
|
function chatHarness(revealInView) {
|
|
const states = []
|
|
const refs = []
|
|
let stateIndex = 0
|
|
let refIndex = 0
|
|
let waits = 0
|
|
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
|
|
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
|
|
react: {
|
|
useEffect() {},
|
|
useState(initial) {
|
|
const index = stateIndex++
|
|
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
|
|
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
|
|
},
|
|
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
|
|
},
|
|
'socket.io-client': {}
|
|
}, {
|
|
URLSearchParams, AbortController, DOMException,
|
|
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
|
|
window: { setTimeout(fn) { waits++; fn() } }
|
|
})
|
|
return {
|
|
render() {
|
|
stateIndex = 0; refIndex = 0
|
|
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
|
|
},
|
|
content, waits: () => waits
|
|
}
|
|
}
|
|
|
|
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
|
|
const harness = chatHarness(true)
|
|
await harness.render().sendQaMessage('조향 설정을 알려줘')
|
|
const state = harness.render()
|
|
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
|
|
assert.equal(answer.content, harness.content)
|
|
assert.equal(typeof answer.revealStartedAt, 'number')
|
|
assert.equal(answer.keepExpanded, true)
|
|
assert.equal(state.aiPending, false)
|
|
assert.equal(state.aiTypingMessageId, null)
|
|
assert.equal(harness.waits(), 0)
|
|
})
|
|
|
|
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
|
|
const harness = chatHarness(false)
|
|
await harness.render().sendQaMessage('질문')
|
|
const state = harness.render()
|
|
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
|
|
assert.ok(harness.waits() > 0)
|
|
assert.equal(state.aiTypingMessageId, null)
|
|
})
|