208 lines
9.6 KiB
JavaScript
208 lines
9.6 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(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 i18n = { t: key => key, useLocaleRender() {} }
|
|
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
|
|
const QaInputBox = () => null
|
|
const ChatTextContextMenu = () => null
|
|
|
|
function elements(root, predicate) {
|
|
if (!React.isValidElement(root)) return []
|
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
|
}
|
|
|
|
function setup(overrides = {}, browser = {}) {
|
|
const state = []
|
|
const effects = []
|
|
let index = 0
|
|
let cleared = 0
|
|
const modeChanges = []
|
|
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
|
'../../i18n': i18n,
|
|
'../../components/AppStatusModals': { ConfirmStatusModal },
|
|
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
|
'../../components/ChatWidgets': { QaInputBox },
|
|
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
|
|
'../../components/ChatImageAttachments': {},
|
|
'../../components/AgentProgressBubble': {},
|
|
'../../components/AgentWorkDuration': {},
|
|
'../../components/AgentCodeReferences': {},
|
|
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
|
|
'react-dom': { createPortal: child => child },
|
|
react: {
|
|
...React,
|
|
useEffect(effect) { effects.push(effect) },
|
|
useState(initial) {
|
|
const slot = index++
|
|
if (!(slot in state)) state[slot] = initial
|
|
return [state[slot], value => { state[slot] = value }]
|
|
}
|
|
}
|
|
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
|
|
const props = {
|
|
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
|
|
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
|
|
onModeChange(mode) { modeChanges.push(mode) },
|
|
onClearConversation() { cleared++ }, ...overrides
|
|
}
|
|
return {
|
|
render() { index = 0; effects.length = 0; return panel(props) },
|
|
cleared: () => cleared,
|
|
runEffects() { effects.forEach(effect => effect()) },
|
|
modeChanges
|
|
}
|
|
}
|
|
|
|
for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) {
|
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
|
test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => {
|
|
const harness = setup({ qaMode, agentProvider })
|
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
|
assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT')
|
|
assert.equal(typeof input.props.onClearConversation, 'function')
|
|
})
|
|
}
|
|
}
|
|
|
|
test('the workspace passes the current provider from agent status to the chat panel', () => {
|
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
|
assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/)
|
|
assert.match(source, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
|
|
})
|
|
|
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
|
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
|
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
|
|
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
|
|
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
|
|
assert.equal(modal(harness.render()), undefined)
|
|
input(harness.render()).props.onClearConversation()
|
|
assert.equal(harness.cleared(), 0)
|
|
modal(harness.render()).props.onCancel()
|
|
assert.equal(modal(harness.render()), undefined)
|
|
assert.equal(harness.cleared(), 0)
|
|
input(harness.render()).props.onClearConversation()
|
|
modal(harness.render()).props.onConfirm()
|
|
assert.equal(harness.cleared(), 1)
|
|
assert.equal(modal(harness.render()), undefined)
|
|
})
|
|
}
|
|
|
|
test('context menu deletion also requests confirmation', () => {
|
|
const harness = setup()
|
|
const root = harness.render()
|
|
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
|
|
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
|
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
|
menu.props.onClear()
|
|
assert.equal(harness.cleared(), 0)
|
|
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
|
|
})
|
|
|
|
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
|
|
test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
|
|
const node = {}
|
|
const harness = setup({}, {
|
|
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
|
|
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
|
|
})
|
|
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
|
|
area.props.onContextMenu({
|
|
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
|
|
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
|
|
})
|
|
await Promise.resolve()
|
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
|
assert.equal(menu.props.menu.kind, 'message')
|
|
assert.equal(Boolean(menu.props.menu.text), selected && inside)
|
|
assert.equal(menu.props.canClear, true)
|
|
assert.equal(harness.cleared(), 0)
|
|
})
|
|
}
|
|
|
|
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
|
|
const harness = setup()
|
|
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
|
|
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
|
|
await Promise.resolve()
|
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
|
menu.props.onClear()
|
|
assert.equal(harness.cleared(), 0)
|
|
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
|
assert.ok(modal)
|
|
modal.props.onConfirm()
|
|
assert.equal(harness.cleared(), 1)
|
|
})
|
|
|
|
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
|
|
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
|
|
const harness = setup(props)
|
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
|
assert.equal(input.props.clearDisabled, true)
|
|
input.props.onClearConversation()
|
|
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
|
|
assert.equal(harness.cleared(), 0)
|
|
})
|
|
}
|
|
|
|
for (const dark of [false, true]) {
|
|
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
|
|
const harness = setup({ dark })
|
|
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
|
|
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
|
assert.equal(modal.props.dark, dark)
|
|
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
|
|
const html = renderToStaticMarkup(modal)
|
|
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
|
|
assert.match(html, /bg-\[#d14b4b\]/)
|
|
})
|
|
}
|
|
|
|
test('input no longer opens a native confirmation dialog', () => {
|
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
|
assert.doesNotMatch(source, /window\.confirm/)
|
|
assert.match(source, /onClick=\{onClearConversation\}/)
|
|
})
|
|
|
|
for (const dark of [false, true]) {
|
|
for (const agentEnabled of [false, true]) {
|
|
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
|
|
const harness = setup({ dark, agentEnabled })
|
|
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
|
|
assert.equal(grids.length, 1)
|
|
const tabs = elements(grids[0], node => node.type === 'button')
|
|
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
|
|
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
|
|
})
|
|
}
|
|
}
|
|
|
|
test('returns to AI when the selected agent is disabled after status loading', () => {
|
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
|
|
harness.render()
|
|
harness.runEffects()
|
|
assert.deepEqual(harness.modeChanges, ['ai'])
|
|
})
|
|
|
|
test('does not reset the selected agent while status is still loading', () => {
|
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
|
|
harness.render()
|
|
harness.runEffects()
|
|
assert.deepEqual(harness.modeChanges, [])
|
|
})
|