feat(news): index news packages and research persistent events

Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
This commit is contained in:
eezd committed 2026-11-04 12:09:58 +08:00
1 parent 07252fea0f
commit 4929a32a0a
69 files changed
+8215 -2092

No files matched your search

@@ -0,0 +1,20 @@
import type * as Research from "./type"
import { request } from "@/http/axios"
export const getSettingsApi = () => request<Research.Settings>({ url: "/risk/settings", method: "get" })
export const saveSettingsApi = (data: Research.Config) => request<null>({ url: "/risk/settings", method: "put", data })
export const enableApi = () => request<null>({ url: "/risk/enable", method: "post" })
export const pauseApi = () => request<null>({ url: "/risk/pause", method: "post" })
export const createSkillsApi = () => request<{ index: number, research: number }>({ url: "/risk/skill-templates", method: "post" })
export const listEventsApi = (q: string, page: number) => request<{ items: Research.Event[], total: number }>({ url: "/risk/events", method: "get", params: { q, page } })
export const getEventApi = (id: number) => request<Research.EventDetail>({ url: `/risk/events/${id}`, method: "get" })
export const retryEventApi = (id: number) => request<null>({ url: `/risk/events/${id}/retry`, method: "post" })
export const listPackagesApi = (q: string, page: number) => request<{ items: Research.Package[], total: number }>({ url: "/risk/packages", method: "get", params: { q, page } })
export const getPackageApi = (id: number) => request<Research.PackageDetail>({ url: `/risk/packages/${id}`, method: "get" })
export const retryPackageApi = (id: number) => request<null>({ url: `/risk/packages/${id}/retry`, method: "post" })
export const listNewsPackagesApi = (id: number, revision: number) => request<Research.Package[]>({ url: `/risk/news/${id}/packages`, method: "get", params: { revision_no: revision } })
export const enrollApi = (news_ids: number[]) => request<null>({ url: "/risk/packages", method: "post", data: { news_ids } })
export const getVocabularyApi = () => request<Research.Vocabulary>({ url: "/risk/tag-vocabulary", method: "get" })
export const saveVocabularyApi = (data: { base_revision: number, entries: Research.VocabularyEntry[] }) => request<Research.Vocabulary>({ url: "/risk/tag-vocabulary", method: "put", data })
export const getEventVersionApi = (event: number, version: number) => request<Research.VersionDetail>({ url: `/risk/events/${event}/versions/${version}`, method: "get" })
+14
View File
@@ -0,0 +1,14 @@
export interface Keyword { canonical: string, aliases: string[], quote: string }
export interface ModelConfig { provider_id: number | null, model: string, reasoning_effort: string | null, skill_id: number | null }
export interface Config { index: ModelConfig, research: ModelConfig, concurrency: number }
export interface Settings { config: Partial<Config>, enabled: boolean, start_at: string }
export interface Package { id: number, news_id: number, title: string, url: string, state: string, error: string | null, keywords: Keyword[], events: { id: number, title: string, reason: string, quote: string }[] }
export interface Point { time_precision: "minute" | "day" | "month" | "unknown", planned_precision: "minute" | "day" | "month" | "unknown", package_ids: number[], time_text: string, description: string, occurred_at: string | null, planned_at: string | null, reported_at: string | null, period_text: string, evidence_refs: { article_id: number, quote: string }[] }
export interface Result { title: string, summary: string, facts: string, reasoning: string, uncertainty: string, opinions: string, counter_evidence: string, watchpoints: string, asset_impacts: string, importance_stars: number, risk_stars: number, confidence: string, verification: string, timeline_points: Point[] }
export interface Event { id: number, title: string, state: string, revision: number, error: string | null, tags: string[], result: Result | null }
export interface EventDetail extends Event { packages: Package[], references: { event_id: number, title: string, version_id: number, role: string, reason: string, changed: boolean }[], versions: { id: number, revision: number, state: string, result: Result | null, error: string | null, usage: unknown, evidence: unknown }[] }
export interface PackageDetail extends Package { article: { title: string, summary: string, content: string }, attempts: { id: number, stage: string, state: string, error: string | null, output: unknown, usage: unknown }[] }
export interface VocabularyEntry { canonical: string, aliases: string[] }
export interface Vocabulary { revision: number, entries: VocabularyEntry[] }
export interface VersionDetail { evidence: unknown, usage: unknown, id: number, event_id: number, revision: number, state: string, result: Result, packages: { id: number, news_id: number }[], articles: { id: number, title: string, summary: string, content: string }[], references: EventDetail["references"] }
+3 -4
View File
@@ -5,14 +5,13 @@ export const getRiskSettingsApi = () => request<Risk.Settings>({ url: "/risk/set
export const saveRiskSettingsApi = (data: Risk.Settings) => request<null>({ url: "/risk/settings", method: "put", data })
export const enableRiskApi = (data: Risk.Settings) => request<null>({ url: "/risk/enable", method: "post", timeout: 650000, data })
export const getRiskOverviewApi = (hours: number) => request<Risk.Overview>({ url: "/risk/overview", method: "get", params: { hours } })
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.RiskEvent[], total: number }>({ url: "/risk/summaries", method: "get", params })
export const getRiskEventApi = (id: number) => request<{ related_events?: { id: number, reason: string }[], corrections?: { id: number, result: unknown }[], grouping_versions?: { id: number, state: string, error: string | null }[], evidence: { id: number, kind: string, data: unknown, fetched_at: string }[], event: Risk.RiskEvent, history: Risk.Analysis[], versions: { id: number, revision: number, state: string, model: string, result: Risk.RiskResult | null, error: string | null, analyzed_at: string | null }[] }>({ url: `/risk/summaries/${id}`, method: "get" })
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params })
export const getRiskPackageApi = (id: number) => request<{ corrections?: { id: number, result: unknown }[], grouping_versions?: { id: number, state: string, error: string | null }[], event: Risk.NewsPackage, history: Risk.Analysis[] }>({ url: `/risk/packages/${id}`, method: "get" })
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean, stage?: string }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/tasks", method: "get", params, notify })
export const retryRiskAnalysisApi = (id: number, stage = "grouping") => request<null>({ url: `/risk/tasks/${id}/retry`, method: "post", params: { stage } })
export const listNewsSummariesApi = (newsId: number, revision: number) => request<Risk.RiskEvent[]>({ url: `/risk/news/${newsId}/summaries`, method: "get", params: { revision_no: revision } })
export const listNewsPackagesApi = (newsId: number, revision: number) => request<Risk.NewsPackage[]>({ url: `/risk/news/${newsId}/packages`, method: "get", params: { revision_no: revision } })
export const createNewsSkillTemplatesApi = (refreshExisting = false) => request<{ grouping_skill_id: number, analysis_skill_id: number, outlook_skill_id: number }>({ url: "/risk/skill-templates", method: "post", data: { refresh_existing: refreshExisting } })
export const listNewsEventsApi = (page = 1) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params: { page } })
export const getNewsEventApi = (id: number) => request<Risk.EventDetail>({ url: `/risk/events/${id}`, method: "get" })
export const previewReprocessApi = (data: Risk.ReprocessRange) => request<Risk.ReprocessPreview>({ url: "/risk/reprocess/preview", method: "post", data })
export const startReprocessApi = (data: { preview_id: string, run_mode?: "shared" | "history_only" }) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
+16 -12
View File
@@ -20,24 +20,19 @@ export interface RiskResult {
related_packages?: { id: number, relation: string, reason: string }[]
evidence_refs?: { article_id: number, quote: string }[]
}
export interface RiskEvent {
main_event_id?: number | null
replacement_ids?: number[]
export interface NewsPackage {
main_event_id: number | null
replacement_ids: number[]
fact: string
tags: string[]
content_kind: string
importance_stars: number
article_count: number
id: number
title: string
category: string
entities: string[]
risk_stars: number
peak_stars: number
result: RiskResult | null
state: string
analyzed_at: string | null
revision: number
published_revision: number
error: string | null
published_at: string
updated_at: string
@@ -115,11 +110,20 @@ export interface NewsEvent {
title: string
state: string
error: string | null
result: { summary: string, importance_stars: number, risk_stars: number, counter_evidence: string, timeline_points: TimelinePoint[], uncertainty: string, opinions: string, watchpoints: string } | null
revision: number
published_revision: number
updated_at: string
tags: string[]
category: string
package_count: number
risk_stars: number
importance_stars: number
result: (RiskResult & { timeline_points: TimelinePoint[] }) | null
}
export interface EventDetail extends NewsEvent {
packages: RiskEvent[]
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[]
packages: NewsPackage[]
evidence: { id: number, version_id: number, data: unknown, fetched_at: string }[]
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"], usage: unknown }[]
}
export interface ReprocessRange { selection?: "range" | "attempted", start?: string, end?: string, modes: ("missing" | "review")[] }
export interface ReprocessPreview { articles: number, packages: number, events: number, total: number, missing: number, review: number, preview_id: string, models: Record<string, { model: string, reasoning_effort: string | null }> }
+2 -2
View File
@@ -18,7 +18,7 @@ let version = 0
let busy = false
let alive = true
let timer: ReturnType<typeof setInterval> | undefined
const labels: Record<string, string> = { grouping: "新闻分包", analysis: "包研判", events: "事件时间线", outlook: "阅读编排", backfill: "历史新闻", running: "运行中", queued: "排队", waiting: "等待条件", failed: "失败", done: "已完成", unknown: "状态未知" }
const labels: Record<string, string> = { grouping: "新闻分包", analysis: "事件研判", events: "事件研判", outlook: "阅读编排", backfill: "历史新闻", running: "运行中", queued: "排队", waiting: "等待条件", failed: "失败", done: "已完成", unknown: "状态未知" }
async function load() {
if (busy) return
busy = true
@@ -82,7 +82,7 @@ onBeforeUnmount(() => {
<el-option v-for="s in ['running', 'queued', 'waiting', 'failed', 'done']" :key="s" :label="labels[s]" :value="s" />
</el-select>
<el-select v-if="expanded" v-model="stage" clearable placeholder="全部阶段" class="!w-36">
<el-option v-for="s in ['grouping', 'analysis', 'events', 'outlook', 'backfill']" :key="s" :label="labels[s]" :value="s" />
<el-option v-for="s in ['grouping', 'events', 'outlook', 'backfill']" :key="s" :label="labels[s]" :value="s" />
</el-select>
<el-select v-if="expanded" v-model="origin" clearable placeholder="全部来源" class="!w-36">
<el-option label="实时" value="realtime" /><el-option label="回补" value="history" />
+12 -26
View File
@@ -6,9 +6,6 @@ import { useUserStore } from "@/pinia/stores/user"
const emit = defineEmits<{ package: [id: number] }>()
const user = useUserStore()
const items = ref<Risk.NewsEvent[]>([])
const total = ref(0)
const page = ref(1)
const detail = ref<Risk.EventDetail | null>(null)
const opened = ref(false)
const detailError = ref(false)
@@ -53,10 +50,8 @@ async function load() {
const seq = ++sequence
const version = user.sessionVersion
try {
const [events, runs] = await Promise.all([api.listNewsEventsApi(page.value), api.listReprocessApi()])
const runs = await api.listReprocessApi()
if (!active || seq !== sequence || version !== user.sessionVersion) return
items.value = events.items
total.value = events.total
jobs.value = runs
error.value = false
if (batchId.value && !batchLoading.value) await openBatch(batchId.value, batchPage.value)
@@ -119,7 +114,6 @@ async function action(id: number, retry: boolean) {
watch([range, modes], () => {
preview.value = null
})
watch(page, load)
watch(() => user.sessionVersion, () => {
batchSequence++
sequence++
@@ -127,7 +121,6 @@ watch(() => user.sessionVersion, () => {
operation++
busy.value = false
detailError.value = false
items.value = []
jobs.value = []
batch.value = null
batchId.value = 0
@@ -160,25 +153,12 @@ defineExpose({ open })
<template>
<el-card shadow="never">
<template #header>
事件与时间线
AI 历史回补
</template>
<el-alert v-if="error" title="事件读取失败,可重试" type="warning" :closable="false" />
<el-button link @click="load">
刷新
</el-button>
<div v-for="event in items" :key="event.id" class="my-3">
<el-button link @click="open(event.id)">
{{ event.title }}
</el-button>
<p>{{ event.result?.summary || '正在整理组成包的时间线' }}</p>
<p v-if="event.result">
重要性 {{ event.result.importance_stars }} 星 · 风险 {{ event.result.risk_stars }}X
</p>
<p v-if="event.error">
更新失败:{{ event.error }};保留上一成功版本
</p>
</div>
<el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" />
<details class="mt-4">
<summary>AI 历史回补</summary>
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p>
@@ -223,7 +203,7 @@ defineExpose({ open })
成功 {{ job.counts.done ?? 0 }} · 处理中 {{ job.counts.processing ?? 0 }} · 待处理 {{ job.counts.pending ?? 0 }} · 失败 {{ job.counts.failed ?? 0 }} · 跳过 {{ job.counts.skipped ?? 0 }}
</p>
<p v-for="(counts, stage) in job.stages" :key="stage">
{{ stage === 'analysis' ? '包研判' : '事件时间线' }}:待处理 {{ counts.pending ?? 0 }} · 运行 {{ counts.processing ?? 0 }} · 完成 {{ counts.done ?? 0 }} · 失败 {{ counts.failed ?? 0 }}
{{ stage === 'analysis' ? '事件研判' : '事件研判' }}:待处理 {{ counts.pending ?? 0 }} · 运行 {{ counts.processing ?? 0 }} · 完成 {{ counts.done ?? 0 }} · 失败 {{ counts.failed ?? 0 }}
</p>
<el-button link @click="openBatch(job.id)">
查看新闻明细
@@ -235,7 +215,7 @@ defineExpose({ open })
重试失败项
</el-button>
<p v-for="failure in job.failures" :key="`${failure.stage}:${failure.id}`">
{{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
{{ ({ grouping: "新闻初筛", analysis: "事件研判", events: "事件研判", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
</p>
</div>
<div v-if="batchId">
@@ -251,7 +231,7 @@ defineExpose({ open })
</div>
</details>
</el-card>
<el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)">
<el-drawer v-model="opened" title="事件研判与时间线" size="min(760px, 100vw)">
<el-alert v-if="detailError" title="事件详情读取失败" type="warning" :closable="false" />
<el-button v-if="detailError" @click="open(detailId)">
重试
@@ -269,6 +249,7 @@ defineExpose({ open })
{{ ref.quote }}
</blockquote>
</div>
<p>事实:{{ detail.result?.facts }}</p><p>市场影响:{{ detail.result?.asset_impacts }}</p><p>判断依据:{{ detail.result?.reasoning }}</p>
<p>反证:{{ detail.result?.counter_evidence }}</p><p>不确定性:{{ detail.result?.uncertainty }}</p><p>意见:{{ detail.result?.opinions }}</p><p>关注:{{ detail.result?.watchpoints }}</p>
<p>组成包</p>
<div v-for="item in detail.packages" :key="item.id">
@@ -276,10 +257,15 @@ defineExpose({ open })
{{ item.title }}
</el-button>
</div>
<p>核验记录</p>
<details v-for="record in detail.evidence" :key="record.id">
<summary>证据 #{{ record.id }} · {{ formatTime(record.fetched_at) }}</summary>
<pre class="whitespace-pre-wrap break-all">{{ record.data }}</pre>
</details>
<p>版本历史</p>
<details v-for="version in detail.versions" :key="version.id" class="my-2">
<summary>v{{ version.revision }} · {{ version.state }} {{ version.error }}</summary>
<p>{{ version.result?.summary }}</p>
<p>{{ version.result?.summary }}</p><pre class="whitespace-pre-wrap break-all">{{ version.usage }}</pre>
<p v-for="(point, index) in version.result?.timeline_points" :key="index">
{{ point.time_text }} · {{ point.description }} · 包 #{{ point.package_id }} v{{ point.package_revision }}
</p>
File diff suppressed because it is too large. Load diff
+12 -11
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import type { Analysis, RiskEvent } from "@/common/apis/risk/type"
import { listNewsSummariesApi, listRiskAnalysesApi } from "@/common/apis/risk"
import type { Package } from "@@/apis/news-research/type"
import { listNewsPackagesApi } from "@@/apis/news-research"
import { useUserStore } from "@/pinia/stores/user"
const props = defineProps<{ newsId: number, revision: number }>()
const user = useUserStore()
const analysis = ref<Analysis | null>(null)
const summaries = ref<RiskEvent[]>([])
const summaries = ref<Package[]>([])
const failed = ref(false)
const loading = ref(false)
const ownerVersion = user.sessionVersion
@@ -19,15 +18,13 @@ async function load(reset = true) {
const version = user.sessionVersion
loading.value = true
if (reset) {
analysis.value = null
summaries.value = []
failed.value = false
}
try {
const [rows, events] = await Promise.all([listRiskAnalysesApi({ news_id: props.newsId, revision_no: props.revision }, reset), listNewsSummariesApi(props.newsId, props.revision)])
const events = await listNewsPackagesApi(props.newsId, props.revision)
if (seq === sequence && version === user.sessionVersion) {
summaries.value = events
analysis.value = rows[0] ?? null
failed.value = false
}
} catch {
@@ -39,7 +36,7 @@ async function load(reset = true) {
watch(() => [props.newsId, props.revision], () => void load(), { immediate: true })
onMounted(() => {
timer = setInterval(() => {
if (!document.hidden && !loading.value && (!["done"].includes(analysis.value?.state ?? "") || summaries.value.some(e => ["pending", "processing"].includes(e.state)))) void load(false)
if (!document.hidden && !loading.value && (!summaries.value.length || summaries.value.some(e => e.state !== "done"))) void load(false)
}, 15000)
})
onBeforeUnmount(() => {
@@ -53,16 +50,20 @@ onBeforeUnmount(() => {
<div class="rounded bg-sky-50 p-3 text-sm">
<b>AI 关联新闻包</b>
<div v-for="event in summaries" :key="event.id" class="my-2">
<router-link :to="{ path: '/home', query: { summary: event.id } }">
<router-link :to="{ path: '/home', query: { package: event.id } }">
{{ event.title }}
</router-link>
<p>{{ event.result?.summary || '事件归并完成,等待二阶段总结' }}</p>
<div class="my-2 flex flex-wrap gap-2">
<el-tag v-for="word in event.keywords" :key="word.canonical">
{{ word.canonical }}
</el-tag>
</div>
<p v-if="event.error">
更新失败:{{ event.error }}
</p>
</div>
<p v-if="!summaries.length">
{{ failed ? '关联事件读取失败' : analysis?.state === 'failed' ? `初筛失败:${analysis.error}` : '暂无关联总结;新流程只处理启用后的新闻' }}
{{ failed ? '关联事件读取失败' : '尚未建立关键词索引' }}
<el-button v-if="failed" link :loading="loading" @click="load()">
重试
</el-button>
+1 -7
View File
@@ -84,13 +84,7 @@ onBeforeUnmount(() => media?.removeEventListener("change", updateMobile))
{{ item.summary || item.excerpt }}
</div>
<div class="mt-1 line-clamp-2 text-xs text-slate-500">
<template v-if="item.risk?.state === 'done'">
<span class="text-amber-600">{{ item.risk.verification === 'insufficient' ? '信息不足,无法判断' : item.risk.stars ? '★'.repeat(item.risk.stars) : '无明显风险' }}</span>
· {{ item.risk.summary }}
</template>
<template v-else>
{{ item.risk?.state === 'skipped' ? '非财经新闻,原文保留' : item.risk?.state === 'failed' ? 'AI 分析失败' : item.risk?.state === 'processing' ? 'AI 分析中' : '未纳入新流程或等待总结' }}
</template>
{{ item.risk?.state === 'done' ? '关键词与事件归属已完成,研判请查看关联事件' : item.risk?.state?.endsWith('_failed') ? '关键词索引或事件分类失败' : item.risk ? '关键词索引与事件分类处理中' : '尚未建立关键词索引' }}
</div>
</div>
<el-alert v-if="failed" type="error" :closable="false" title="新闻读取失败;已有内容保留">
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import test from "node:test"
import * as vue from "vue"
import { moduleLoader } from "./module-loader.ts"
test("event archives and fact packages use separate API contracts", async () => {
const calls: { url: string }[] = []
const load = moduleLoader({ "@/http/axios": { request: async (config: { url: string }) => calls.push(config) } })
const api = load("@@/apis/risk")
await api.listRiskEventsApi({ importance: 3, hours: 72 })
await api.getNewsEventApi(9)
await api.getRiskPackageApi(4)
await api.listNewsPackagesApi(12, 2)
assert.deepEqual(calls.map(c => c.url), ["/risk/events", "/risk/events/9", "/risk/packages/4", "/risk/news/12/packages"])
})
test("switching event details discards previous event and session responses", async () => {
const first = Promise.withResolvers<any>()
const second = Promise.withResolvers<any>()
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@/common/apis/risk": { getNewsEventApi: (id: number) => id === 1 ? first.promise : second.promise },
"@/pinia/stores/user": { useUserStore: () => user },
"@@/utils/text": { formatTime: (s: string) => s }
}, { ...vue, onMounted() {}, onBeforeUnmount() {}, setInterval() {}, clearInterval() {} })
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {} })
const a = page.open(1)
const b = page.open(2)
second.resolve({ id: 2, title: "Second event", packages: [{ id: 4, fact: "Original fact" }] })
await b
first.resolve({ id: 1, title: "Old event" })
await a
assert.equal(page.detail.value.id, 2)
assert.equal(page.detail.value.packages[0].fact, "Original fact")
const pending = page.open(1)
user.sessionVersion++
await vue.nextTick()
await pending
assert.equal(page.detail.value, null)
assert.equal(page.opened.value, false)
})
+358 -344
View File
@@ -340,164 +340,13 @@ test("交易确认绑定发起币种;交易所或会话切换后拒绝旧操
assert.equal(writes.length, 1)
})
test("风险首页丢弃旧窗口与旧会话结果,详情关闭后不写回", async () => {
const requests: ReturnType<typeof Promise.withResolvers>[] = []
const details = Promise.withResolvers()
const user = { sessionVersion: 1 }
const load = moduleLoader({
"@/common/apis/risk": {
getRiskOverviewApi: () => {
const request = Promise.withResolvers()
requests.push(request)
return request.promise
},
listRiskEventsApi: async () => ({ items: [], total: 0 }),
getRiskEventApi: () => details.promise
},
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => user }
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
const old = page.load()
const current = page.load()
requests[1].resolve({ as_of: "current" })
await current
requests[0].resolve({ as_of: "old" })
await old
assert.equal(page.overview.value.as_of, "current")
const previous = page.load()
user.sessionVersion++
requests[2].resolve({ as_of: "other-session" })
await previous
assert.equal(page.overview.value.as_of, "current")
const opening = page.openEvent(1)
await vue.nextTick()
page.showDetail.value = false
await vue.nextTick()
details.resolve({ event: { id: 1 }, history: [] })
await opening
assert.equal(page.detail.value, null)
})
test("新闻模型列表随接入配置更新,保留手填值并丢弃旧配置、关闭及旧会话结果", async () => {
const requests: { id: number, response: ReturnType<typeof Promise.withResolvers> }[] = []
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ provider_id: 1, model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
listProviderModelsApi: (id: number) => {
const response = Promise.withResolvers()
requests.push({ id, response })
return response.promise
}
},
"@/pinia/stores/user": { useUserStore: () => user }
}, { ...globals, clearInterval, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.openSettings()
await vue.nextTick()
assert.equal(requests[0].id, 1)
page.settings.value.provider_id = 2
await vue.nextTick()
requests[1].response.resolve(["model-two"])
await setImmediate()
requests[0].response.resolve(["model-one"])
await setImmediate()
assert.deepEqual(Array.from(page.models.value), ["model-two"])
assert.equal(page.settings.value.model, "manual-model")
const failed = page.loadModels()
requests[2].response.reject(new Error("目录不可用"))
await failed
assert.equal(page.modelsError.value, "目录不可用")
assert.equal(page.settings.value.model, "manual-model")
const closed = page.loadModels()
page.showSettings.value = false
await vue.nextTick()
requests[3].response.resolve(["closed"])
await closed
assert.equal(page.models.value.length, 0)
await page.openSettings()
await vue.nextTick()
user.sessionVersion++
await vue.nextTick()
requests[4].response.resolve(["previous-session"])
await setImmediate()
assert.equal(page.showSettings.value, false)
assert.equal(page.models.value.length, 0)
await page.openSettings()
await vue.nextTick()
unmount()
requests[5].response.resolve(["unmounted"])
await setImmediate()
assert.equal(page.models.value.length, 0)
})
test("第二阶段模型列表随接入配置更新,保留手填值并丢弃旧配置、关闭及旧会话结果", async () => {
const requests: { id: number, response: ReturnType<typeof Promise.withResolvers> }[] = []
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ second_provider_id: 1, second_model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
listProviderModelsApi: (id: number) => {
const response = Promise.withResolvers()
requests.push({ id, response })
return response.promise
}
},
"@/pinia/stores/user": { useUserStore: () => user }
}, { ...globals, clearInterval, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.openSettings()
await vue.nextTick()
assert.equal(requests[0].id, 1)
page.settings.value.second_provider_id = 2
await vue.nextTick()
requests[1].response.resolve(["model-two"])
await setImmediate()
requests[0].response.resolve(["model-one"])
await setImmediate()
assert.deepEqual(Array.from(page.secondModels.value), ["model-two"])
assert.equal(page.settings.value.second_model, "manual-model")
const failed = page.loadSecondModels()
requests[2].response.reject(new Error("目录不可用"))
await failed
assert.equal(page.secondModelsError.value, "目录不可用")
assert.equal(page.settings.value.second_model, "manual-model")
const closed = page.loadSecondModels()
page.showSettings.value = false
await vue.nextTick()
requests[3].response.resolve(["closed"])
await closed
assert.equal(page.secondModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
user.sessionVersion++
await vue.nextTick()
requests[4].response.resolve(["previous-session"])
await setImmediate()
assert.equal(page.showSettings.value, false)
assert.equal(page.secondModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
unmount()
requests[5].response.resolve(["unmounted"])
await setImmediate()
assert.equal(page.secondModels.value.length, 0)
})
test("新闻 AI 详情绑定新闻修订版本,旧分析响应不能覆盖当前版本", async () => {
const requests: { params: unknown, response: ReturnType<typeof Promise.withResolvers> }[] = []
const props = vue.reactive({ newsId: 1, revision: 1 })
const user = { sessionVersion: 1 }
const load = moduleLoader({
"@/common/apis/risk": { listNewsSummariesApi: async () => [], listRiskAnalysesApi: (params: unknown) => {
"@@/apis/news-research": { listNewsPackagesApi: (id: number, revision: number) => {
const params = { news_id: id, revision_no: revision }
const response = Promise.withResolvers()
requests.push({ params, response })
return response.promise
@@ -512,13 +361,13 @@ test("新闻 AI 详情绑定新闻修订版本,旧分析响应不能覆盖当
await setImmediate()
requests[0].response.resolve([{ id: 1 }])
await setImmediate()
assert.equal(page.analysis.value.id, 2)
assert.equal(page.summaries.value[0].id, 2)
props.newsId = 3
await vue.nextTick()
user.sessionVersion++
requests[2].response.resolve([{ id: 3 }])
await setImmediate()
assert.equal(page.analysis.value, null)
assert.equal(page.summaries.value.length, 0)
})
test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧会话和卸载不继续请求", async () => {
@@ -530,7 +379,7 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧
const document = { hidden: false }
const user = { sessionVersion: 1 }
const load = moduleLoader({
"@/common/apis/risk": { listNewsSummariesApi: async () => [], listRiskAnalysesApi: async () => {
"@@/apis/news-research": { listNewsPackagesApi: async () => {
requests++
return [{ state }]
} },
@@ -555,7 +404,7 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧
const props = vue.reactive({ newsId: 1, revision: 1 })
const page = load("@/pages/news/components/NewsRisk.vue").default.setup(props, { expose() {} })
await setImmediate()
assert.equal(page.analysis.value.state, "pending")
assert.equal(page.summaries.value[0].state, "pending")
document.hidden = true
tick()
assert.equal(requests, 1)
@@ -564,7 +413,7 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧
tick()
tick()
await setImmediate()
assert.equal(page.analysis.value.state, "done")
assert.equal(page.summaries.value[0].state, "done")
assert.equal(requests, 2)
tick()
assert.equal(requests, 2)
@@ -634,146 +483,6 @@ test("新闻列表只更新可见条目的匹配版本评级,保留滚动与
assert.equal(queries.length, 5)
})
test("第三阶段模型列表随接入配置更新,保留手填值并丢弃旧配置、关闭及旧会话结果", async () => {
const requests: { id: number, response: ReturnType<typeof Promise.withResolvers> }[] = []
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ third_provider_id: 1, third_model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
listProviderModelsApi: (id: number) => {
const response = Promise.withResolvers()
requests.push({ id, response })
return response.promise
}
},
"@/pinia/stores/user": { useUserStore: () => user }
}, { ...globals, clearInterval, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.openSettings()
await vue.nextTick()
assert.equal(requests[0].id, 1)
page.settings.value.third_provider_id = 2
await vue.nextTick()
requests[1].response.resolve(["model-two"])
await setImmediate()
requests[0].response.resolve(["model-one"])
await setImmediate()
assert.deepEqual(Array.from(page.thirdModels.value), ["model-two"])
assert.equal(page.settings.value.third_model, "manual-model")
const failed = page.loadThirdModels()
requests[2].response.reject(new Error("目录不可用"))
await failed
assert.equal(page.thirdError.value, "目录不可用")
assert.equal(page.settings.value.third_model, "manual-model")
const closed = page.loadThirdModels()
page.showSettings.value = false
await vue.nextTick()
requests[3].response.resolve(["closed"])
await closed
assert.equal(page.thirdModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
user.sessionVersion++
await vue.nextTick()
requests[4].response.resolve(["previous-session"])
await setImmediate()
assert.equal(page.showSettings.value, false)
assert.equal(page.thirdModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
unmount()
requests[5].response.resolve(["unmounted"])
await setImmediate()
assert.equal(page.thirdModels.value.length, 0)
})
test("72小时阅读列表分页保留全部包,更新缩页后回到有效页", async () => {
let count = 45
const load = moduleLoader({
"@/common/apis/risk": {
getRiskOverviewApi: async () => ({ briefing: { reading: Array.from({ length: count }, (_, id) => ({ id })) } }),
listRiskEventsApi: async () => ({ items: [], total: 0 })
},
"@@/apis/skills": {},
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) }
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.load()
assert.equal(page.readingRows.value.length, 20)
page.readingPage.value = 3
assert.deepEqual(Array.from(page.readingRows.value, (row: { id: number }) => row.id), [40, 41, 42, 43, 44])
count = 1
await page.load()
assert.equal(page.readingPage.value, 1)
assert.equal(page.readingRows.value[0].id, 0)
})
test("创建新闻 Skills 后刷新列表,旧会话响应不能覆盖新会话", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const response = Promise.withResolvers<unknown[]>()
let notifications = 0
const load = moduleLoader({
"@/common/apis/risk": { createNewsSkillTemplatesApi: async () => ({ grouping_skill_id: 1 }) },
"@@/apis/skills": { listSkillTreeApi: () => response.promise },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => user }
}, {
...globals,
ElMessage: { success() {
notifications++
} }
})
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
page.settings.value = { grouping_skill_id: null }
const pending = page.createTemplates()
await setImmediate()
user.sessionVersion++
await vue.nextTick()
page.newsSkills.value = [{ id: 2, scope: "news", kind: "skill", name: "Current" }]
response.resolve([{ id: 1, scope: "news", kind: "skill", name: "Old" }])
await pending
assert.equal(page.newsSkills.value[0].id, 2)
assert.equal(notifications, 0)
})
test("三阶段思考强度独立保存,重建模板显式请求新版草稿", async () => {
let saved: Record<string, unknown> = {}
let refresh: unknown
const config = { enabled: false, model: "luna", reasoning_effort: null, second_reasoning_effort: null, third_reasoning_effort: null }
const load = moduleLoader({
"@/common/apis/risk": {
saveRiskSettingsApi: async (data: Record<string, unknown>) => saved = data,
getRiskOverviewApi: async () => ({ coverage: {}, counts: {} }),
listRiskEventsApi: async () => ({ items: [], total: 0 }),
createNewsSkillTemplatesApi: async (value: unknown) => {
refresh = value
return { grouping_skill_id: 1, analysis_skill_id: 2, outlook_skill_id: 3 }
}
},
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) }
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
page.settings.value = { ...config, reasoning_effort: "low", second_reasoning_effort: "high", third_reasoning_effort: "xhigh" }
await page.save(false)
assert.equal(saved.reasoning_effort, "low")
assert.equal(saved.second_reasoning_effort, "high")
assert.equal(saved.third_reasoning_effort, "xhigh")
page.settings.value.second_reasoning_effort = null
await page.save(false)
assert.equal(saved.reasoning_effort, "low")
assert.equal(saved.second_reasoning_effort, null)
assert.equal(saved.third_reasoning_effort, "xhigh")
await page.createTemplates()
assert.equal(refresh, true)
assert.equal(page.settings.value.analysis_skill_id, 2)
})
test("事件详情忽略旧请求与旧会话,失败可重试", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
@@ -837,52 +546,6 @@ test("重整理预览的旧会话完成不能解除新操作的忙碌状态", as
assert.equal(page.preview.value.articles, 2)
})
test("新闻并发独立保存保留模型草稿,重复点击和旧会话响应不影响新操作", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const values: number[] = []
let reloads = 0
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => user },
"@/common/apis/risk": {
saveRiskConcurrencyApi: (value: number) => {
values.push(value)
const request = Promise.withResolvers()
pending.push(request)
return request.promise
},
getRiskOverviewApi: async () => {
reloads++
return { briefing: null }
},
listRiskEventsApi: async () => ({ items: [], total: 0 })
}
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
page.settings.value = { max_concurrency: 5, model: "unsaved-model" }
const first = page.saveConcurrency()
await page.saveConcurrency()
assert.deepEqual(values, [5])
pending[0].resolve({ max_concurrency: 5 })
await first
assert.equal(page.settings.value.model, "unsaved-model")
assert.equal(reloads, 1)
const old = page.saveConcurrency()
user.sessionVersion++
await vue.nextTick()
page.settings.value = { max_concurrency: 2, model: "new-session" }
const current = page.saveConcurrency()
pending[1].resolve({ max_concurrency: 5 })
await old
assert.equal(page.concurrencySaving.value, true)
assert.equal(page.settings.value.model, "new-session")
pending[2].resolve({ max_concurrency: 2 })
await current
assert.equal(page.concurrencySaving.value, false)
})
test("AI 队列隔离旧会话与旧筛选响应,轮询隐藏页面时暂停并在卸载后清理", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
@@ -1050,3 +713,354 @@ test("回补明细刷新不抢占新选择,旧会话与旧请求不能覆盖
assert.equal(page.batchId.value, 9)
assert.equal(page.batchPage.value, 1)
})
function researchPage(api: Record<string, unknown> = {}, user = vue.reactive({ sessionVersion: 1 }), extra: Record<string, unknown> = {}, dependencies: Record<string, unknown> = {}) {
const load = moduleLoader({
"@@/apis/news-research": { getSettingsApi: async () => ({ config: {}, enabled: false }), listEventsApi: async () => ({ items: [], total: 0 }), listPackagesApi: async () => ({ items: [], total: 0 }), ...api },
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@@/apis/settings": { listProvidersApi: async () => [], listProviderModelsApi: async () => [] },
"@/pinia/stores/user": { useUserStore: () => user },
...dependencies
}, { ...globals, clearInterval, ...extra })
return load("@/pages/home/index.vue").default.setup({}, { expose() {} })
}
test("两阶段首页保留新查询结果,旧会话与卸载后的响应不写回", async () => {
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const user = vue.reactive({ sessionVersion: 1 })
let unmount = () => {}
const page = researchPage({ listEventsApi: () => {
const p = Promise.withResolvers()
pending.push(p)
return p.promise
} }, user, { onBeforeUnmount: (fn: () => void) => unmount = fn })
const first = page.load()
const second = page.load()
pending[1].resolve({ items: [{ id: 2 }], total: 1 })
await second
pending[0].resolve({ items: [{ id: 1 }], total: 1 })
await first
assert.equal(page.events.value[0].id, 2)
const old = page.load()
user.sessionVersion++
await vue.nextTick()
pending[2].resolve({ items: [{ id: 3 }], total: 1 })
await old
assert.equal(page.events.value.length, 0)
unmount()
await page.load()
assert.equal(pending.length, 3)
})
for (const stage of ["index", "research"] as const) {
test(`${stage} 模型列表保持手填值并拒绝旧配置与关闭后的响应`, async () => {
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const page = researchPage({}, undefined, {}, { "@@/apis/settings": { listProvidersApi: async () => [], listProviderModelsApi: () => {
const p = Promise.withResolvers()
pending.push(p)
return p.promise
} } })
page.config.value[stage].model = "manual"
page.settingsOpened.value = true
page.config.value[stage].provider_id = 1
await vue.nextTick()
page.config.value[stage].provider_id = 2
await vue.nextTick()
pending[1].resolve(["new"])
await setImmediate()
pending[0].resolve(["old"])
await setImmediate()
assert.deepEqual(Array.from(page.modelOptions.value[stage]), ["new"])
assert.equal(page.config.value[stage].model, "manual")
const closed = page.loadModels(stage)
page.settingsOpened.value = false
await vue.nextTick()
pending[2].resolve(["closed"])
await closed
assert.equal(page.modelOptions.value[stage].length, 0)
})
}
test("事件与包详情丢弃旧请求和关闭后响应,失败可重新读取", async () => {
for (const kind of ["Event", "Package"]) {
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const page = researchPage({ [`get${kind}Api`]: () => {
const p = Promise.withResolvers()
pending.push(p)
return p.promise
} })
const first = page[`open${kind}`](1)
const second = page[`open${kind}`](2)
pending[1].resolve({ id: 2 })
await second
pending[0].resolve({ id: 1 })
await first
const ref = kind === "Event" ? page.detail : page.packageDetail
assert.equal(ref.value.id, 2)
const closed = page[`open${kind}`](3)
;(kind === "Event" ? page.opened : page.packageOpened).value = false
await vue.nextTick()
pending[2].resolve({ id: 3 })
await closed
assert.equal(ref.value, null)
const failed = page[`open${kind}`](4)
pending[3].reject(new Error("unavailable"))
await failed
assert.equal((kind === "Event" ? page.detailError : page.packageError).value, true)
const retry = page[`open${kind}`](4)
pending[4].resolve({ id: 4 })
await retry
assert.equal(ref.value.id, 4)
}
})
test("两阶段保存各自思考强度,不提交第三阶段配置", async () => {
let saved: any
const page = researchPage({ saveSettingsApi: async (data: unknown) => {
saved = JSON.parse(JSON.stringify(data))
} })
page.config.value.index.reasoning_effort = "low"
page.config.value.research.reasoning_effort = "high"
await page.save()
assert.equal(saved.index.reasoning_effort, "low")
assert.equal(saved.research.reasoning_effort, "high")
assert.equal(Object.keys(saved).length, 3)
page.config.value.research.reasoning_effort = null
await page.save()
assert.equal(saved.research.reasoning_effort, null)
assert.equal(saved.index.reasoning_effort, "low")
})
test("Skill 草稿的旧会话响应不能替换当前列表或提示成功", async () => {
const pending = Promise.withResolvers()
const user = vue.reactive({ sessionVersion: 1 })
let notices = 0
const page = researchPage({ createSkillsApi: async () => ({ index: 1, research: 2 }) }, user, { ElMessage: { success() {
notices++
} } }, { "@@/apis/skills": { listSkillTreeApi: () => pending.promise } })
const old = page.prepareSkills()
await setImmediate()
user.sessionVersion++
await vue.nextTick()
page.skills.value = [{ id: 9 }]
pending.resolve([{ id: 1, scope: "news", kind: "skill" }])
await old
assert.equal(page.skills.value[0].id, 9)
assert.equal(notices, 0)
})
test("两阶段操作拒绝重复提交和旧会话操作,失败保留配置草稿", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending = Promise.withResolvers()
let calls = 0
const page = researchPage({ saveSettingsApi: () => {
calls++
return pending.promise
} }, user)
page.config.value.index.model = "draft"
const first = page.save()
await page.save()
assert.equal(calls, 1)
pending.reject(new Error("save failed"))
await assert.rejects(first, /save failed/)
assert.equal(page.config.value.index.model, "draft")
assert.equal(page.busy.value, false)
user.sessionVersion++
await vue.nextTick()
await page.save()
assert.equal(calls, 1)
})
test("事件与新闻包分别分页查询,不调用第三阶段", async () => {
const queries: string[] = []
const page = researchPage({ listEventsApi: async () => {
queries.push("events")
return { items: [{ id: 1 }], total: 31 }
}, listPackagesApi: async () => {
queries.push("packages")
return { items: [{ id: 2 }], total: 30 }
} })
await page.load()
assert.equal(page.events.value[0].id, 1)
page.tab.value = "packages"
await vue.nextTick()
await setImmediate()
assert.equal(page.packages.value[0].id, 2)
assert.equal(page.total.value, 30)
assert.deepEqual(queries, ["events", "packages"])
})
for (const kind of ["Event", "Package"] as const) {
test(`${kind} 重试后刷新详情,轮询恢复读取失败并丢弃关闭后的响应`, async () => {
let state = "failed"
let reads = 0
let writes = 0
let failRead = false
const deferred = Promise.withResolvers()
let delay = false
const page = researchPage({
[`get${kind}Api`]: async (id: number) => {
reads++
if (delay) return deferred.promise
if (failRead) throw new Error("GET failed")
return { id, state, error: state === "failed" ? "Old error" : null, versions: state === "done" ? [1] : [] }
},
[`retry${kind}Api`]: async () => {
writes++
state = "pending"
}
})
const detail = kind === "Event" ? page.detail : page.packageDetail
const opened = kind === "Event" ? page.opened : page.packageOpened
const error = kind === "Event" ? page.detailError : page.packageError
await page[`open${kind}`](7)
await page[`retry${kind}`]()
assert.equal(detail.value.state, "pending")
assert.equal(detail.value.error, null)
state = "done"
failRead = true
await page.refresh()
assert.equal(detail.value.state, "pending")
assert.equal(error.value, true)
failRead = false
await page.refresh()
assert.equal(detail.value.state, "done")
assert.equal(detail.value.versions.length, 1)
assert.equal(error.value, false)
delay = true
const pending = page.refresh()
await setImmediate()
const count = reads
await page.refresh()
assert.equal(reads, count)
opened.value = false
await vue.nextTick()
deferred.resolve({ id: 7, state: "failed" })
await pending
assert.equal(detail.value.state, "done")
assert.equal(writes, 1)
})
}
test("固定版本读取丢弃旧请求及旧会话响应", async () => {
const first = Promise.withResolvers<any>()
const second = Promise.withResolvers<any>()
const user = vue.reactive({ sessionVersion: 1 })
const page = researchPage({ getEventVersionApi: (_event: number, version: number) => version === 1 ? first.promise : second.promise }, user)
const a = page.openVersion(8, 1)
const b = page.openVersion(8, 2)
second.resolve({ id: 2 })
await b
first.resolve({ id: 1 })
await a
assert.equal(page.versionDetail.value.id, 2)
user.sessionVersion++
await vue.nextTick()
assert.equal(page.versionDetail.value, null)
assert.equal(page.versionOpened.value, false)
})
test("词库读取不能打开新会话界面", async () => {
const pending = Promise.withResolvers<any>()
const user = vue.reactive({ sessionVersion: 1 })
const page = researchPage({ getVocabularyApi: () => pending.promise }, user)
const reading = page.openVocabulary()
user.sessionVersion++
await vue.nextTick()
pending.resolve({ revision: 1, entries: [] })
await reading
assert.equal(page.vocabularyOpened.value, false)
})
test("词库读取保留最新草稿,关闭和卸载后不接收迟到响应", async () => {
const pending: ReturnType<typeof Promise.withResolvers<any>>[] = []
let unmount = () => {}
const page = researchPage({ getVocabularyApi: () => {
const value = Promise.withResolvers<any>()
pending.push(value)
return value.promise
} }, undefined, { onBeforeUnmount: (fn: () => void) => unmount = fn })
const first = page.openVocabulary()
const second = page.openVocabulary()
pending[1].resolve({ revision: 2, entries: [{ canonical: "新词", aliases: [] }] })
await second
page.vocabulary.value.entries[0].canonical = "编辑草稿"
pending[0].resolve({ revision: 1, entries: [] })
await first
assert.equal(page.vocabulary.value.revision, 2)
assert.equal(page.vocabulary.value.entries[0].canonical, "编辑草稿")
const closed = page.openVocabulary()
page.vocabularyOpened.value = false
await vue.nextTick()
pending[2].resolve({ revision: 3, entries: [] })
await closed
assert.equal(page.vocabularyOpened.value, false)
const disposed = page.openVocabulary()
unmount()
pending[3].resolve({ revision: 4, entries: [] })
await disposed
assert.notEqual(page.vocabulary.value.revision, 4)
await page.openVocabulary()
assert.equal(pending.length, 4)
})
test("词库读取失败允许重试,未读取完成不能保存", async () => {
const pending = Promise.withResolvers<any>()
let reads = 0
let writes = 0
const page = researchPage({
getVocabularyApi: () => {
reads++
if (reads === 1) return pending.promise
return Promise.resolve({ revision: 2, entries: [] })
},
saveVocabularyApi: async () => {
writes++
}
})
const reading = page.openVocabulary()
await page.saveVocabulary()
assert.equal(writes, 0)
pending.reject(new Error("unavailable"))
await reading
assert.equal(page.vocabularyError.value, true)
assert.equal(page.vocabularyLoading.value, false)
await page.saveVocabulary()
assert.equal(writes, 0)
await page.openVocabulary()
assert.equal(page.vocabulary.value.revision, 2)
assert.equal(page.vocabularyError.value, false)
})
test("词库保存拒绝重复提交,关闭后的响应不覆盖草稿,失败保留编辑", async () => {
const pending = Promise.withResolvers<any>()
let reads = 0
let writes = 0
const page = researchPage({
getVocabularyApi: async () => ({ revision: ++reads, entries: [{ canonical: "词条", aliases: [] }] }),
saveVocabularyApi: () => {
writes++
if (writes === 1) return pending.promise
return Promise.reject(new Error("conflict"))
}
})
await page.openVocabulary()
const saving = page.saveVocabulary()
await page.saveVocabulary()
assert.equal(writes, 1)
page.vocabularyOpened.value = false
await vue.nextTick()
await page.openVocabulary()
assert.equal(reads, 1)
page.vocabulary.value.entries[0].canonical = "保留草稿"
pending.resolve({ revision: 2, entries: [] })
await saving
assert.equal(page.vocabularyOpened.value, false)
assert.equal(page.vocabulary.value.entries[0].canonical, "保留草稿")
assert.equal(page.vocabularyBusy.value, false)
await page.openVocabulary()
page.vocabulary.value.entries[0].canonical = "失败草稿"
await assert.rejects(page.saveVocabulary(), /conflict/)
assert.equal(page.vocabulary.value.entries[0].canonical, "失败草稿")
assert.equal(page.vocabularyOpened.value, true)
assert.equal(page.vocabularyBusy.value, false)
})