diff --git a/web/src/pages/chat/components/ContextTab.vue b/web/src/pages/chat/components/ContextTab.vue index 59f9786..77aedbd 100644 --- a/web/src/pages/chat/components/ContextTab.vue +++ b/web/src/pages/chat/components/ContextTab.vue @@ -12,19 +12,32 @@ const versions = ref([]) const saving = ref(false) const dirty = computed(() => content.value !== (current.value?.content ?? "")) const preview = computed(() => renderMarkdown(content.value)) +let loadSeq = 0 async function load() { - current.value = await getChatContext() - content.value = current.value.content - versions.value = await listChatContextVersions() + if (saving.value) + return + const seq = ++loadSeq + const previous = content.value + const [context, history] = await Promise.all([getChatContext(), listChatContextVersions()]) + if (seq !== loadSeq) + return + current.value = context + versions.value = history + if (content.value === previous) + content.value = context.content } async function save() { saving.value = true + // 失效保存前的读取,保存期间也不启动新的读取,避免旧版本覆盖编辑区。 + ++loadSeq try { - await saveChatContext(content.value) + const saved = await saveChatContext(content.value) + // 保存回执就是已保存版本,编辑区保留后续输入。 + current.value = saved ElMessage.success("已保存") - await load() + versions.value = await listChatContextVersions() } finally { saving.value = false } diff --git a/web/tests/page-races.test.ts b/web/tests/page-races.test.ts index 1469515..4eb7b77 100644 --- a/web/tests/page-races.test.ts +++ b/web/tests/page-races.test.ts @@ -14,10 +14,10 @@ interface Version { version_no: number } -function setupPage(page: string, modules: Record, confirm: () => Promise = async () => {}) { - const filename = new URL(`../src/pages/${page}/index.vue`, import.meta.url) +function setupComponent(component: string, modules: Record, confirm: () => Promise = async () => {}) { + const filename = new URL(`../src/pages/${component}`, import.meta.url) const { descriptor } = parse(readFileSync(filename, "utf8")) - const script = compileScript(descriptor, { id: page }).content + 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, @@ -28,17 +28,17 @@ function setupPage(page: string, modules: Record, confirm: () = onBeforeUnmount() {}, onBeforeRouteLeave() {}, setInterval() {}, - ElMessage: { success() {} }, + 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: `${page}.vue` }) + runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component }) return mod.exports.default.setup({}, { expose() {} }) } function skills(api: Record, confirm?: () => Promise) { - return setupPage("skills", { + return setupComponent("skills/index.vue", { "@/apis/skills": api, "@/components/SkillTree.vue": {}, "@/components/SkillVersionDrawer.vue": {}, @@ -115,7 +115,7 @@ async function checkPublishSwitch() { async function checkTickerSwitch() { const btc = Promise.withResolvers>() const eth = Promise.withResolvers>() - const page = setupPage("market", { + 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: [] }, @@ -155,9 +155,123 @@ async function checkPublishConfirmationSwitch() { assert.deepEqual(published, []) } +function contextEditor(api: Record) { + 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() -console.log("页面异步竞态:5 项回归检查通过") +await checkContextRefresh() +await checkContextSave() +await checkContextReloadAfterSave() +await checkContextRefreshDuringSave() +await checkContextFailedSave() +console.log("页面异步竞态:10 项回归检查通过")