131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import type { ChatMessage } from '../src/shared/types'
|
|
import MessageBubble from '../src/renderer/src/components/MessageBubble'
|
|
import {
|
|
getSourceLoadingTips,
|
|
pickNextLoadingTip,
|
|
resolveLoadingTipLocale,
|
|
scheduleLoadingTips,
|
|
STREAMING_TIP_INITIAL_DELAY_MS,
|
|
STREAMING_TIP_ROTATION_INTERVAL_MS,
|
|
type LoadingTipScheduler
|
|
} from '../src/renderer/src/workbuddy/conversation/StreamingFooter'
|
|
import { SOURCE_LOADING_TIPS } from '../src/renderer/src/workbuddy/conversation/streaming-loading-tips'
|
|
|
|
type TimerId = ReturnType<typeof globalThis.setTimeout>
|
|
class FakeScheduler implements LoadingTipScheduler {
|
|
private now = 0
|
|
private nextId = 0
|
|
private timers = new Map<number, { at: number; callback: () => void; interval: number }>()
|
|
|
|
setTimeout(callback: () => void, delayMs: number): TimerId {
|
|
return this.add(callback, delayMs, 0)
|
|
}
|
|
|
|
clearTimeout(timer: TimerId): void {
|
|
this.timers.delete(timer as unknown as number)
|
|
}
|
|
|
|
setInterval(callback: () => void, intervalMs: number): TimerId {
|
|
return this.add(callback, intervalMs, intervalMs)
|
|
}
|
|
|
|
clearInterval(timer: TimerId): void {
|
|
this.timers.delete(timer as unknown as number)
|
|
}
|
|
|
|
tick(durationMs: number): void {
|
|
const end = this.now + durationMs
|
|
for (;;) {
|
|
const due = [...this.timers.entries()].filter(([, timer]) => timer.at <= end)
|
|
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]
|
|
if (!due) break
|
|
const [id, timer] = due
|
|
this.now = timer.at
|
|
if (timer.interval === 0) this.timers.delete(id)
|
|
else timer.at += timer.interval
|
|
timer.callback()
|
|
}
|
|
this.now = end
|
|
}
|
|
|
|
private add(callback: () => void, delayMs: number, interval: number): TimerId {
|
|
const id = this.nextId++
|
|
this.timers.set(id, { at: this.now + delayMs, callback, interval })
|
|
return id as unknown as TimerId
|
|
}
|
|
}
|
|
|
|
assert.equal(STREAMING_TIP_INITIAL_DELAY_MS, 4_000)
|
|
assert.equal(STREAMING_TIP_ROTATION_INTERVAL_MS, 10_000)
|
|
assert.equal(SOURCE_LOADING_TIPS.en.length, 188)
|
|
assert.equal(SOURCE_LOADING_TIPS.zh.length, 275)
|
|
assert.ok(SOURCE_LOADING_TIPS.en.every(Boolean))
|
|
assert.ok(SOURCE_LOADING_TIPS.zh.every(Boolean))
|
|
assert.equal(resolveLoadingTipLocale('pt-BR'), 'en')
|
|
assert.equal(resolveLoadingTipLocale('id-ID'), 'en')
|
|
assert.equal(resolveLoadingTipLocale('zh-CN'), 'zh')
|
|
assert.equal(resolveLoadingTipLocale(undefined), 'zh')
|
|
assert.ok(getSourceLoadingTips('en-US', 'YCode').some(tip => tip.includes('YCode')))
|
|
assert.ok(getSourceLoadingTips('zh-CN', 'YCode').some(tip => tip.includes('YCode')))
|
|
assert.ok(getSourceLoadingTips('en-US', 'YCode').every(tip => !tip.includes('WorkBuddy')))
|
|
assert.ok(getSourceLoadingTips('zh-CN', 'YCode').every(tip => !tip.includes('WorkBuddy')))
|
|
assert.equal(pickNextLoadingTip([], null), null)
|
|
assert.equal(pickNextLoadingTip(['only'], 'only'), 'only')
|
|
assert.equal(pickNextLoadingTip(['first', 'second'], 'first', () => 0), 'second')
|
|
|
|
const scheduler = new FakeScheduler()
|
|
const shown: string[] = []
|
|
let paused = false
|
|
const cancel = scheduleLoadingTips({
|
|
tips: ['first tip', 'second tip'],
|
|
onTip: tip => shown.push(tip),
|
|
isPaused: () => paused,
|
|
scheduler
|
|
})
|
|
scheduler.tick(3_999)
|
|
assert.deepEqual(shown, [], 'the first tip waits for the source 4 second delay')
|
|
scheduler.tick(1)
|
|
assert.equal(shown.length, 1)
|
|
const firstTip = shown[0]
|
|
paused = true
|
|
scheduler.tick(10_000)
|
|
assert.equal(shown.length, 1, 'hovering pauses tip rotation')
|
|
paused = false
|
|
scheduler.tick(10_000)
|
|
assert.equal(shown.length, 2, 'tip rotation resumes on the next 10 second interval')
|
|
assert.notEqual(shown[1], firstTip, 'adjacent tips do not repeat')
|
|
cancel()
|
|
scheduler.tick(30_000)
|
|
assert.equal(shown.length, 2, 'unmount cleanup stops future rotations')
|
|
|
|
const streamingMessage = (content: string, parts: ChatMessage['parts']): ChatMessage => ({
|
|
id: `streaming-${content ? 'body' : 'empty'}`, role: 'assistant', status: 'streaming', createdAt: 1, content, parts
|
|
})
|
|
const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />)
|
|
assert.match(emptyStream, /wb-cr-streaming-footer/)
|
|
assert.match(emptyStream, /思考中|Thinking/)
|
|
assert.doesNotMatch(emptyStream, /bubble__cursor/)
|
|
|
|
const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />)
|
|
assert.match(bodyStream, /partial /)
|
|
assert.match(bodyStream, /markdown-content__loading-char/)
|
|
assert.match(bodyStream, /wb-cr-streaming-footer/, 'the streaming footer remains while answer text is arriving')
|
|
|
|
const completed = renderToStaticMarkup(<MessageBubble message={{
|
|
id: 'completed-footer', role: 'assistant', status: 'done', createdAt: 1, content: 'final answer',
|
|
parts: [{ type: 'text', text: 'final answer' }], modelId: 'model-1', modelName: 'gpt-6-luna'
|
|
}} />)
|
|
assert.doesNotMatch(completed, /wb-cr-streaming-footer/)
|
|
assert.match(completed, /gpt-6-luna/, 'the completed message retains its frozen model label')
|
|
|
|
const recoveredCss = readFileSync('src/renderer/src/workbuddy/conversation/streaming-footer-source.css', 'utf8')
|
|
assert.match(recoveredCss, /width:min\(100%,760px\)/)
|
|
assert.match(recoveredCss, /animation:2\.2s linear infinite _cb-shining-text-sweep_13r4w_1/)
|
|
assert.match(recoveredCss, /animation:\.32s ease-out forwards _cb-tip-fade-in_13r4w_1/)
|
|
assert.match(recoveredCss, /margin-bottom:12px/)
|
|
|
|
console.log('streaming-footer-regression: persistent stream status, delayed/paused rotation, brand/locale mapping, source CSS, and completed model footer passed')
|