Files
lms-fe/scripts/test-chat-clear-confirmation.cjs

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, [])
})