Files
AlphaPulse/web/tests/page-races.test.ts
T

278 lines
10 KiB
TypeScript

import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { createRequire, Module } from "node:module"
import { runInNewContext } from "node:vm"
import ts from "typescript"
import * as vue from "vue"
import { compileScript, parse } from "vue/compiler-sfc"
const require = createRequire(import.meta.url)
interface Version {
id: number
content: string
version_no: number
}
function setupComponent(component: string, modules: Record<string, unknown>, confirm: () => Promise<unknown> = async () => {}) {
const filename = new URL(`../src/pages/${component}`, import.meta.url)
const { descriptor } = parse(readFileSync(filename, "utf8"))
const script = compileScript(descriptor, { id: component }).content
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const globals = {
ref: vue.ref,
reactive: vue.reactive,
computed: vue.computed,
watch: vue.watch,
onMounted() {},
onBeforeUnmount() {},
onBeforeRouteLeave() {},
setInterval() {},
ElMessage: { success() {}, warning() {} },
ElMessageBox: { confirm }
}
const mod = new Module(filename.pathname)
const load = (path: string) => path in modules ? modules[path] : require(path)
runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component })
return mod.exports.default.setup({}, { expose() {} })
}
function skills(api: Record<string, unknown>, confirm?: () => Promise<unknown>) {
return setupComponent("skills/index.vue", {
"@/apis/skills": api,
"@/components/SkillTree.vue": {},
"@/components/SkillVersionDrawer.vue": {},
"@/utils/markdown": { renderMarkdown: (text: string) => text }
}, confirm)
}
async function checkSkillSwitch() {
const a = Promise.withResolvers<Version[]>()
const b = Promise.withResolvers<Version[]>()
const drafts: { id: number, content: string }[] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise,
saveSkillDraft: async (id: number, content: string) => {
drafts.push({ id, content })
return { id: 20, version_no: 2, content }
}
})
await page.loadTree()
const first = page.select(nodes[0])
const second = page.select(nodes[1])
assert.equal(await page.saveDraft(), null)
b.resolve([{ id: 2, content: "B 原文", version_no: 1 }])
await second
a.resolve([{ id: 1, content: "A 迟到", version_no: 1 }])
await first
assert.equal(page.content.value, "B 原文")
page.content.value = "B 修改"
await page.saveDraft()
assert.equal(drafts.length, 1)
assert.equal(drafts[0].id, 2)
assert.equal(drafts[0].content, "B 修改")
}
async function checkSkillReload() {
const old = Promise.withResolvers<Version[]>()
let calls = 0
const page = skills({
listSkillTree: async () => [{ id: 1, kind: "skill" }],
listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
})
await page.loadTree()
page.selectedId.value = 1
const first = page.loadContent()
await page.loadContent()
old.resolve([{ id: 1, content: "旧内容", version_no: 1 }])
await first
assert.equal(page.content.value, "最新")
}
async function checkPublishSwitch() {
const saved = Promise.withResolvers<Version>()
const published: [number, number][] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }],
saveSkillDraft: () => saved.promise,
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
})
await page.loadTree()
await page.select(nodes[0])
page.content.value = "待发布修改"
const publishing = page.publishLatest()
await page.select(nodes[1])
saved.resolve({ id: 10, version_no: 2, content: "待发布修改" })
await publishing
assert.deepEqual(published, [])
assert.equal(page.content.value, "内容 2")
}
async function checkTickerSwitch() {
const btc = Promise.withResolvers<Record<string, string>>()
const eth = Promise.withResolvers<Record<string, string>>()
const page = setupComponent("market/index.vue", {
"@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
"@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
"./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] },
"./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) },
"./components/AddSymbolDialog.vue": {},
"./components/SymbolManager.vue": {},
"klinecharts": {}
})
const first = page.loadTicker()
page.selection.value = { market: "um", symbol: "ETHUSDT" }
await vue.nextTick()
eth.resolve({ lastPrice: "2000", highPrice: "2100", lowPrice: "1900", volume: "5", quoteVolume: "10000", priceChangePercent: "1" })
await vue.nextTick()
await new Promise(resolve => setImmediate(resolve))
btc.resolve({ lastPrice: "80000", highPrice: "81000", lowPrice: "79000", volume: "1", quoteVolume: "80000", priceChangePercent: "2" })
await first
assert.equal(page.ticker.last, 2000)
assert.equal(page.ticker.changePercent, 1)
}
async function checkPublishConfirmationSwitch() {
const confirmation = Promise.withResolvers<void>()
const published: [number, number][] = []
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
const page = skills({
listSkillTree: async () => nodes,
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
}, () => confirmation.promise)
await page.loadTree()
await page.select(nodes[0])
const publishing = page.publishLatest()
await Promise.resolve()
await page.select(nodes[1])
confirmation.resolve()
await publishing
assert.deepEqual(published, [])
}
function contextEditor(api: Record<string, unknown>) {
return setupComponent("chat/components/ContextTab.vue", {
"@/apis/chat": api,
"@/utils/markdown": { renderMarkdown: (text: string) => text },
"@/utils/text": { formatTime: (text: string) => text }
})
}
async function checkContextRefresh() {
const updated = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => []
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
api.getChatContext = () => updated.promise
const refresh = page.refreshFromChat()
page.content.value = "刷新期间的新输入"
updated.resolve({ id: 2, content: "Chat 更新" })
await refresh
assert.equal(page.current.value.content, "Chat 更新")
assert.equal(page.content.value, "刷新期间的新输入")
assert.equal(page.dirty.value, true)
}
async function checkContextSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
let submitted = ""
const page = contextEditor({
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: (content: string) => {
submitted = content
return saved.promise
}
})
await new Promise(resolve => setImmediate(resolve))
page.content.value = "提交的修改"
const saving = page.save()
page.content.value = "保存期间继续输入"
saved.resolve({ id: 2, content: submitted })
await saving
assert.equal(submitted, "提交的修改")
assert.equal(page.current.value.content, "提交的修改")
assert.equal(page.content.value, "保存期间继续输入")
assert.equal(page.dirty.value, true)
}
async function checkContextReloadAfterSave() {
const stale = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: async (content: string) => ({ id: 2, content })
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
api.getChatContext = () => stale.promise
const loading = page.load()
page.content.value = "已保存修改"
await page.save()
stale.resolve({ id: 1, content: "旧响应" })
await loading
assert.equal(page.current.value.id, 2)
assert.equal(page.content.value, "已保存修改")
assert.equal(page.dirty.value, false)
}
async function checkContextRefreshDuringSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
const page = contextEditor({
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: () => saved.promise
})
await new Promise(resolve => setImmediate(resolve))
page.content.value = "提交的修改"
const saving = page.save()
await page.load()
assert.equal(page.content.value, "提交的修改")
saved.resolve({ id: 2, content: "提交的修改" })
await saving
assert.equal(page.dirty.value, false)
}
async function checkContextFailedSave() {
const saved = Promise.withResolvers<{ id: number, content: string }>()
const stale = Promise.withResolvers<{ id: number, content: string }>()
const api = {
getChatContext: async () => ({ id: 1, content: "原内容" }),
listChatContextVersions: async () => [],
saveChatContext: () => saved.promise
}
const page = contextEditor(api)
await new Promise(resolve => setImmediate(resolve))
page.content.value = "保存失败后应保留的草稿"
const saving = page.save()
api.getChatContext = () => stale.promise
const loading = page.load()
saved.reject(new Error("保存失败"))
await assert.rejects(saving, /保存失败/)
stale.resolve({ id: 1, content: "原内容" })
await loading
assert.equal(page.content.value, "保存失败后应保留的草稿")
assert.equal(page.dirty.value, true)
assert.equal(page.saving.value, false)
}
await checkSkillSwitch()
await checkSkillReload()
await checkPublishSwitch()
await checkTickerSwitch()
await checkPublishConfirmationSwitch()
await checkContextRefresh()
await checkContextSave()
await checkContextReloadAfterSave()
await checkContextRefreshDuringSave()
await checkContextFailedSave()
console.log("页面异步竞态:10 项回归检查通过")