diff --git a/backend/src/alphapulse/news_research/api.py b/backend/src/alphapulse/news_research/api.py index 9b5d895..b92558a 100644 --- a/backend/src/alphapulse/news_research/api.py +++ b/backend/src/alphapulse/news_research/api.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import Field +from pydantic import AwareDatetime, Field from sqlalchemy import select, update, func, or_, text from sqlalchemy.dialects.postgresql import insert from alphapulse.core.db import get_session @@ -119,6 +119,15 @@ async def settings(session: DB): ) +@router.get("/coverage") +async def coverage(session: DB, start_at: AwareDatetime | None = None): + row = await settings_row(session) + start = (start_at if start_at is not None else row.start_at).astimezone(UTC) + if start > datetime.now(UTC): + raise HTTPException(422, "回补起始时间不能晚于当前时间") + return success(await batches.coverage(session, start)) + + @router.put("/settings") async def save(body: SettingsInput, session: DB): await settings_row(session) @@ -147,9 +156,16 @@ async def pause(session: DB): return success(None) +class EnableInput(Strict): + start_at: AwareDatetime + + @router.post("/enable") -async def enable(session: DB): +async def enable(session: DB, body: EnableInput | None = None): row = await settings_row(session) + start_at = (body.start_at if body is not None else row.start_at).astimezone(UTC) + if start_at > datetime.now(UTC): + raise HTTPException(422, "回补起始时间不能晚于当前时间") if not await session.scalar(text("SELECT pg_try_advisory_xact_lock(20261103, 2)")): raise HTTPException(409, "模型验证已在进行中") if row.enabled: @@ -242,10 +258,12 @@ async def enable(session: DB): ) if row.control_revision != control_revision or row.config != original_config: raise HTTPException(409, "验证期间已暂停或设置变化,请重新验证") + report = await batches.coverage(session, start_at) + row.start_at = start_at row.enabled = True row.control_revision += 1 await session.commit() - return success(None) + return success(report) @router.post("/skill-templates") diff --git a/backend/src/alphapulse/news_research/batches.py b/backend/src/alphapulse/news_research/batches.py index 8ef87ef..0f2b8b3 100644 --- a/backend/src/alphapulse/news_research/batches.py +++ b/backend/src/alphapulse/news_research/batches.py @@ -1,12 +1,51 @@ """Durable admission barriers: finish this batch's assignments before any research.""" from collections import Counter from datetime import UTC, datetime -from sqlalchemy import select, or_, and_, exists, func, any_ +from sqlalchemy import select, or_, and_, exists, func, any_, case from alphapulse.models import NewsItem from alphapulse.news_research.models import Batch, BatchPackage, BatchEvent, Package, Event, Membership, Attempt from alphapulse.news_research.service import enroll +async def coverage(session, start_at, cutoff=None): + """Count each original once, including unfinished research after assignment.""" + cutoff = cutoff or datetime.now(UTC) + research = select( + Membership.package_id, + func.bool_or(Event.state == 'failed').label('failed'), + func.bool_or(Event.state == 'processing').label('processing'), + func.bool_or(Event.state.not_in(['done', 'retired'])).label('unfinished'), + ).join(Event, Event.id == Membership.event_id).group_by(Membership.package_id).subquery() + state = case( + (Package.id.is_(None), 'missing'), + (Package.content_hash != NewsItem.content_hash, 'stale'), + (Package.state.in_(['index_failed', 'assign_failed']), 'failed'), + (Package.state.in_(['index_processing', 'assign_processing']), 'processing'), + (Package.state != 'done', 'pending'), + (research.c.failed, 'failed'), + (research.c.processing, 'processing'), + (or_(research.c.package_id.is_(None), research.c.unfinished), 'pending'), + else_='completed', + ) + in_range = and_(NewsItem.published_at >= start_at, NewsItem.published_at <= cutoff) + rows = (await session.execute( + select(in_range, state, func.count()).select_from(NewsItem) + .outerjoin(Package, Package.news_id == NewsItem.id) + .outerjoin(research, research.c.package_id == Package.id) + .where(or_(in_range, Package.id.is_not(None))) + .group_by(in_range, state) + )).all() + counts = {status: count for selected, status, count in rows if selected} + outside_counts = {status: count for selected, status, count in rows if not selected} + outside = {name: outside_counts.get(name, 0) for name in + ('stale', 'pending', 'processing', 'failed')} + result = {name: counts.get(name, 0) for name in + ('missing', 'stale', 'pending', 'processing', 'failed', 'completed')} + return {'start_at': start_at, 'checked_at': cutoff, 'total': sum(result.values()), + 'unprocessed': sum(result.values()) - result['completed'], **result, + 'outside': {'unprocessed': sum(outside.values()), **outside}} + + async def freeze(session, settings): cutoff = datetime.now(UTC) ids = list(await session.scalars(select(NewsItem.id).outerjoin(Package, Package.news_id == NewsItem.id).where( diff --git a/backend/tests/test_news_research_workspace.py b/backend/tests/test_news_research_workspace.py index 04f8627..e13d2e5 100644 --- a/backend/tests/test_news_research_workspace.py +++ b/backend/tests/test_news_research_workspace.py @@ -260,6 +260,54 @@ def test_settings_contract_rejects_third_stage_and_unknown_efforts(trading): ) +def test_backfill_start_requires_an_explicit_timezone(trading): + from alphapulse.news_research.api import EnableInput + + selected = EnableInput(start_at="2026-10-01T00:00:00+08:00") + assert selected.start_at == datetime(2026, 9, 30, 16, tzinfo=UTC) + with pytest.raises(ValueError, match="timezone_aware"): + EnableInput(start_at="2026-10-01T00:00:00") + + +@pytest.mark.parametrize("endpoint", ["coverage", "enable"]) +def test_future_backfill_is_rejected_before_model_calls(trading, monkeypatch, endpoint): + import asyncio + from unittest.mock import AsyncMock + from fastapi import HTTPException + from alphapulse.news_research import api, service + + future = datetime.now(UTC) + timedelta(days=1) + row = N(start_at=datetime.now(UTC), enabled=False) + session = N(get=AsyncMock(return_value=row), commit=AsyncMock()) + config = AsyncMock(side_effect=AssertionError("不应调用模型")) + monkeypatch.setattr(service, "model_config", config) + call = (api.enable(session, api.EnableInput(start_at=future)) if endpoint == "enable" + else api.coverage(session, start_at=future)) + with pytest.raises(HTTPException) as error: + asyncio.run(call) + assert error.value.status_code == 422 + assert not row.enabled + config.assert_not_called() + session.commit.assert_not_called() + + +def test_failed_enable_does_not_save_the_selected_backfill_start(trading, monkeypatch): + import asyncio + from unittest.mock import AsyncMock + from fastapi import HTTPException + from alphapulse.news_research import api, service + + previous = datetime(2026, 10, 1, tzinfo=UTC) + row = N(start_at=previous, enabled=False, config={}, control_revision=7) + session = N(get=AsyncMock(return_value=row), scalar=AsyncMock(side_effect=[True, 0, 0]), commit=AsyncMock()) + monkeypatch.setattr(service, "model_config", AsyncMock(side_effect=ValueError("Skill 不可用"))) + with pytest.raises(HTTPException) as error: + asyncio.run(api.enable(session, api.EnableInput(start_at=previous - timedelta(days=30)))) + assert error.value.status_code == 502 + assert row.start_at == previous and not row.enabled and row.control_revision == 7 + session.commit.assert_not_called() + + @pytest.mark.parametrize("tool", ["read_news", "read_package"]) def test_compressed_members_read_frozen_positive_sources_and_reject_old_cache( trading, tool diff --git a/scripts/smoke_news_batches.py b/scripts/smoke_news_batches.py index 60c1c28..38e1839 100644 --- a/scripts/smoke_news_batches.py +++ b/scripts/smoke_news_batches.py @@ -87,11 +87,117 @@ async def main(): for model in (BatchEvent, BatchPackage, Batch, Membership, Attempt, Package, Event, NewsItem): await s.execute(delete(model)) await s.commit() + await backfill_regressions(now) + await carryover_regressions(now) await regressions(now, settings) await engine.dispose() print('Frozen batches passed: assignment barrier, single research, failures, new arrivals, restart and source revisions.') +async def backfill_regressions(now): + start = now - timedelta(days=2) + async with SessionLocal() as s: + def original(key, published_at): + return NewsItem(source_code='wscn_news', external_id='coverage-'+key, + title=key, summary='', content_text=key, content_hash='a'*64, + url='https://example.invalid/'+key, published_at=published_at) + keys = ['boundary', 'done', 'research-pending', 'research-failed', + 'index-failed', 'processing', 'assign-pending', 'stale'] + news = {key: original(key, start if key == 'boundary' else now) for key in keys} + older = original('older', start-timedelta(microseconds=1)) + future = original('future', now+timedelta(days=1)) + s.add_all([*news.values(), older, future]) + await s.flush() + states = {'index-failed': 'index_failed', 'processing': 'index_processing', + 'assign-pending': 'assign_pending'} + packages = {key: Package(news_id=row.id, content_hash='b'*64 if key == 'stale' else row.content_hash, + state=states.get(key, 'done'), keywords=[]) + for key, row in news.items() if key != 'boundary'} + events = {state: Event(title='coverage-'+state, state=state, tags=[], revision=1, + result={} if state == 'done' else None, due_at=now) + for state in ['done', 'pending', 'failed']} + s.add_all([*packages.values(), *events.values()]) + await s.flush() + for key, state in [('done', 'done'), ('research-pending', 'pending'), + ('research-failed', 'failed'), ('research-failed', 'done'), ('stale', 'done')]: + s.add(Membership(package_id=packages[key].id, event_id=events[state].id, + evidence=key, reason='coverage fixture', change_kind='new_fact')) + await s.commit() + before = await s.scalar(select(func.count()).select_from(Package)) + report = await batches.coverage(s, start, now) + assert {k: report[k] for k in ['total', 'completed', 'missing', 'stale', 'pending', 'processing', 'failed', 'unprocessed']} == { + 'total': 8, 'completed': 1, 'missing': 1, 'stale': 1, 'pending': 2, + 'processing': 1, 'failed': 2, 'unprocessed': 7}, report + assert await s.scalar(select(func.count()).select_from(Package)) == before + # Historical news arriving after a scan must still enter the next frozen batch. + late = original('late-arrival', start+timedelta(hours=1)) + s.add(late) + packages['processing'].state = 'index_pending' + await s.commit() + report = await batches.coverage(s, start, now) + assert report['total'] == 9 and report['missing'] == 2 + batch = await batches.freeze(s, SimpleNamespace(start_at=start)) + admitted = set(await s.scalars(select(Package.news_id).join(BatchPackage).where(BatchPackage.batch_id == batch.id))) + assert {news['boundary'].id, late.id} <= admitted + assert not {news['done'].id, older.id, future.id} & admitted + assert packages['stale'].state == 'index_pending' + assert packages['stale'].content_hash == news['stale'].content_hash + # Moving the start backwards discovers originals missed by the former boundary. + expanded = await batches.freeze(s, SimpleNamespace(start_at=older.published_at)) + admitted = set(await s.scalars(select(Package.news_id).join(BatchPackage).where(BatchPackage.batch_id == expanded.id))) + assert older.id in admitted and future.id not in admitted + assert all(row.content_hash == 'a'*64 for row in [*news.values(), older, future, late]) + for model in (BatchEvent, BatchPackage, Batch, Membership, Attempt, Package, Event, NewsItem): + await s.execute(delete(model)) + await s.commit() + print('Backfill coverage passed: inclusive boundary, pending research, distinct news counts, late arrivals, source revisions and expanded history without model calls.') + + +async def carryover_regressions(now): + start = now - timedelta(days=2) + async with SessionLocal() as s: + keys = ['inside', 'pending', 'processing', 'failed', 'stale', 'research', 'done', 'unselected', 'future'] + news = {key: NewsItem(source_code='wscn_news', external_id='carryover-'+key, + title=key, summary='', content_text=key, content_hash='a'*64, + url='https://example.invalid/'+key, + published_at=now if key == 'inside' else + now+timedelta(days=1) if key == 'future' else start-timedelta(days=1)) + for key in keys} + s.add_all(news.values()) + await s.flush() + states = {'pending': 'index_pending', 'processing': 'assign_processing', + 'failed': 'index_failed', 'future': 'assign_pending'} + packages = {key: Package(news_id=row.id, content_hash='b'*64 if key == 'stale' else row.content_hash, + state=states.get(key, 'done'), keywords=[]) + for key, row in news.items() if key != 'unselected'} + done = Event(title='carryover-done', tags=[], state='done', result={}, revision=1, due_at=now) + pending = Event(title='carryover-pending', tags=[], state='pending', revision=1, due_at=now) + batch = Batch(phase='assigning', cutoff_at=now) + s.add_all([*packages.values(), done, pending, batch]) + await s.flush() + for key, event in [('inside', done), ('done', done), ('research', done), ('research', pending)]: + s.add(Membership(package_id=packages[key].id, event_id=event.id, + evidence=key, reason='carryover fixture', change_kind='new_fact')) + for key in ['pending', 'processing', 'stale']: + s.add(BatchPackage(batch_id=batch.id, package_id=packages[key].id, + content_hash=packages[key].content_hash, state='pending')) + s.add(BatchEvent(batch_id=batch.id, event_id=pending.id, revision=1, state='pending')) + await s.commit() + wide = await batches.coverage(s, start-timedelta(days=2), now) + assert wide['outside']['unprocessed'] == 1 # Explicitly enrolled future original. + narrow = await batches.coverage(s, start, now) + assert narrow['total'] == 1 and narrow['completed'] == 1 and narrow['unprocessed'] == 0 + assert narrow['outside'] == {'unprocessed': 6, 'pending': 3, 'processing': 1, 'failed': 1, 'stale': 1}, narrow + assert await s.scalar(select(func.count()).select_from(BatchPackage)) == 3 + assert await s.scalar(select(func.count()).select_from(BatchEvent)) == 1 + assert await s.scalar(select(func.count()).select_from(Package)) == 8 + assert batch.phase == 'assigning' and packages['pending'].state == 'index_pending' + for model in (BatchEvent, BatchPackage, Batch, Membership, Attempt, Package, Event, NewsItem): + await s.execute(delete(model)) + await s.commit() + print('Carryover coverage passed: narrowing preserves and reports outside queued news, active work, changed originals, pending research and explicit future enrollment without counting completed or unselected originals.') + + async def regressions(now, settings): async def clear(session): # This script runs first in an empty, disposable CI database. TRUNCATE avoids diff --git a/scripts/smoke_news_research.py b/scripts/smoke_news_research.py index 3861b0b..f6f6977 100644 --- a/scripts/smoke_news_research.py +++ b/scripts/smoke_news_research.py @@ -382,12 +382,25 @@ async def main(): published_skill = await s.get(SkillVersion, node.published_version_id) published_skill.content = valid_content await s.commit() + previous_start = (await client.get("/api/risk/settings")).json()["data"]["start_at"] + selected_start = "2026-10-01T00:00:00+08:00" + with patch.object(model_service, "complete", side_effect=AssertionError("核查和无效时间不应调用模型")): + for invalid in ["2026-10-01T00:00:00", "2999-01-01T00:00:00Z"]: + assert (await client.post("/api/risk/enable", json={"start_at": invalid})).status_code == 422 + assert (await client.get("/api/risk/coverage", params={"start_at": invalid})).status_code == 422 + report = await client.get("/api/risk/coverage", params={"start_at": selected_start}) + assert report.status_code == 200, report.text + assert report.json()["data"]["start_at"] == "2026-09-30T16:00:00Z" + assert set(report.json()["data"]["outside"]) == {"unprocessed", "stale", "pending", "processing", "failed"} + assert (await client.get("/api/risk/settings")).json()["data"]["start_at"] == previous_start with patch.object(model_service, "complete", validation): - response = await client.post("/api/risk/enable") + response = await client.post("/api/risk/enable", json={"start_at": selected_start}) assert response.status_code == 200, response.text + assert response.json()["data"]["start_at"] == "2026-09-30T16:00:00Z" assert (await client.get("/api/risk/settings")).json()["data"][ "enabled" ] is True + assert (await client.get("/api/risk/settings")).json()["data"]["start_at"] == "2026-09-30T16:00:00Z" assert (await client.post("/api/risk/enable")).status_code == 409 assert (await client.post("/api/risk/pause")).status_code == 200 for action in ("pause", "save"): @@ -408,11 +421,12 @@ async def main(): return await validation(provider, skill, payload, schema) with patch.object(model_service, "complete", interrupted_validation): - response = await client.post("/api/risk/enable") + response = await client.post("/api/risk/enable", json={"start_at": "2026-09-01T00:00:00Z"}) assert response.status_code == 409, response.text assert (await client.get("/api/risk/settings")).json()["data"][ "enabled" ] is False + assert (await client.get("/api/risk/settings")).json()["data"]["start_at"] == "2026-09-30T16:00:00Z" package_id = (await client.get(f"/api/risk/news/{news_id}/packages")).json()[ "data" ][0]["id"] diff --git a/web/src/common/apis/news-research/index.ts b/web/src/common/apis/news-research/index.ts index 36c04c6..46a6462 100644 --- a/web/src/common/apis/news-research/index.ts +++ b/web/src/common/apis/news-research/index.ts @@ -3,7 +3,8 @@ import { request } from "@/http/axios" export const getSettingsApi = () => request({ url: "/risk/settings", method: "get" }) export const saveSettingsApi = (data: Research.Config) => request({ url: "/risk/settings", method: "put", data }) -export const enableApi = () => request({ url: "/risk/enable", method: "post" }) +export const enableApi = (start_at: string) => request({ url: "/risk/enable", method: "post", data: { start_at } }) +export const getCoverageApi = (start_at: string) => request({ url: "/risk/coverage", method: "get", params: { start_at } }) export const pauseApi = () => request({ 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 } }) diff --git a/web/src/common/apis/news-research/type.ts b/web/src/common/apis/news-research/type.ts index 20a1d91..b4aff7e 100644 --- a/web/src/common/apis/news-research/type.ts +++ b/web/src/common/apis/news-research/type.ts @@ -3,6 +3,7 @@ export interface ModelConfig { provider_id: number | null, model: string, reason export interface Config { index: ModelConfig, research: ModelConfig, concurrency: number } export interface BatchProgress { id: number, phase: string, cutoff_at: string, packages_total: number, packages_done: number, packages_failed: number, packages_deferred: number, events_total: number, events_done: number, events_failed: number, events_deferred: number } export interface Settings { batch?: BatchProgress | null, config: Partial, enabled: boolean, start_at: string } +export interface Coverage { start_at: string, checked_at: string, total: number, unprocessed: number, missing: number, stale: number, pending: number, processing: number, failed: number, completed: number, outside: { unprocessed: number, stale: number, pending: number, processing: number, failed: number } } 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[] } diff --git a/web/src/pages/home/index.vue b/web/src/pages/home/index.vue index 1ab5265..69a96d1 100644 --- a/web/src/pages/home/index.vue +++ b/web/src/pages/home/index.vue @@ -1,5 +1,5 @@ @@ -314,7 +364,7 @@ onBeforeUnmount(() => { 模型与 Skills - + {{ enabled ? '暂停新任务' : '启用两阶段处理' }} @@ -327,6 +377,40 @@ onBeforeUnmount(() => {

新闻包保存原文和关键词;事件汇集相关报道,持续更新判断与时间线。

+
+
+ + + 按浏览器本地时区 + + 核查遗漏 + +
+

+ 启用时保存起点,自动核查并补齐该时间以来已采集的新闻;先完成关键词与归属,再集中研究事件。调整起点不会取消已有队列。 +

+

+ 范围内 {{ coverage.total }} 条,全部阶段已完成 {{ coverage.completed }} 条,未完成 {{ coverage.unprocessed }} 条: + 未入队 {{ coverage.missing }},原文更新需重判 {{ coverage.stale }},待处理 {{ coverage.pending }},处理中 {{ coverage.processing }},失败 {{ coverage.failed }}。 + 失败项需手动重试,当前批次以外的遗漏排入下一批。 +

+

+ 范围外另有已入队但未完成的新闻 {{ coverage.outside.unprocessed }} 条: + 原文更新需重判 {{ coverage.outside.stale }},待处理 {{ coverage.outside.pending }},处理中 {{ coverage.outside.processing }},失败 {{ coverage.outside.failed }}。 + 已有任务继续按批次处理,失败项需手动重试。 +

+

+ 核查失败,请重试。 +

+

批次 #{{ batch.id }} · {{ batchPhases[batch.phase] ?? batch.phase }} · 新闻归属 {{ batch.packages_done }}/{{ batch.packages_total }},失败 {{ batch.packages_failed }},延期 {{ batch.packages_deferred }} · diff --git a/web/tests/workspaces.test.ts b/web/tests/workspaces.test.ts index a170ae6..096d356 100644 --- a/web/tests/workspaces.test.ts +++ b/web/tests/workspaces.test.ts @@ -485,7 +485,7 @@ test("新闻列表只更新可见条目的匹配版本评级,保留滚动与 function researchPage(api: Record = {}, user = vue.reactive({ sessionVersion: 1 }), extra: Record = {}, dependencies: Record = {}) { const load = moduleLoader({ - "@@/apis/news-research": { getSettingsApi: async () => ({ config: {}, enabled: false }), listEventsApi: async () => ({ items: [], total: 0 }), listPackagesApi: async () => ({ items: [], total: 0 }), ...api }, + "@@/apis/news-research": { getSettingsApi: async () => ({ config: {}, enabled: false, start_at: "2026-10-31T16:00:00Z" }), getCoverageApi: async (start_at: string) => ({ start_at, total: 0 }), 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 }, @@ -494,6 +494,136 @@ function researchPage(api: Record = {}, user = vue.reactive({ s return load("@/pages/home/index.vue").default.setup({}, { expose() {} }) } +test("回补时间按时区提交,刷新保留未提交的起点,核查不启用 AI", async () => { + const checked: string[] = [] + const enabled: string[] = [] + const page = researchPage({ + getCoverageApi: async (start_at: string) => { + checked.push(start_at) + return { start_at, total: 10, missing: 4 } + }, + enableApi: async (start_at: string) => { + enabled.push(start_at) + return { start_at, total: 10 } + } + }) + await page.load() + assert.equal(page.startAt.value.toISOString(), "2026-10-31T16:00:00.000Z") + page.startAt.value = new Date("2026-10-01T00:00:00+08:00") + await page.load() + assert.equal(page.startAt.value.toISOString(), "2026-09-30T16:00:00.000Z") + assert.equal(checked.at(-1), "2026-09-30T16:00:00.000Z") + assert.equal(enabled.length, 0) + assert.equal(page.coverage.value.missing, 4) + await page.enable() + assert.deepEqual(enabled, ["2026-09-30T16:00:00.000Z"]) +}) + +test("其他标签页启用后按实际起点核查,暂停后恢复草稿并丢弃旧范围响应", async () => { + let server = { config: {}, enabled: false, start_at: "2026-10-31T16:00:00Z" } + const pending = Promise.withResolvers() + const checked: string[] = [] + let delayDraft = true + const draft = "2026-10-01T00:00:00.000Z" + const actual = "2026-11-05T00:00:00.000Z" + const page = researchPage({ + getSettingsApi: async () => ({ ...server }), + getCoverageApi: async (start_at: string) => { + checked.push(start_at) + if (start_at === draft && delayDraft) { + delayDraft = false + return pending.promise + } + return { start_at, total: 5 } + } + }) + await page.load() + page.selectedStartAt.value = new Date(draft) + const old = page.checkCoverage() + server = { ...server, enabled: true, start_at: actual } + await page.load() + assert.equal(page.enabled.value, true) + assert.equal(page.selectedStartAt.value.toISOString(), actual) + assert.equal(page.coverage.value.start_at, actual) + assert.equal(page.startAt.value.toISOString(), draft) + pending.resolve({ start_at: draft, total: 999 }) + await old + assert.equal(page.coverage.value.start_at, actual) + assert.equal(page.coverage.value.total, 5) + server.enabled = false + await page.load() + assert.equal(page.selectedStartAt.value.toISOString(), draft) + assert.equal(page.coverage.value.start_at, draft) + assert.equal(page.savedStartAt.value.toISOString(), actual) + assert.deepEqual(checked, ["2026-10-31T16:00:00.000Z", draft, actual, draft]) +}) + +test("回补核查丢弃旧时间、旧会话及卸载后的结果,失败可重试", async () => { + const pending: ReturnType[] = [] + const user = vue.reactive({ sessionVersion: 1 }) + let unmount = () => {} + const page = researchPage({ getCoverageApi: () => { + const p = Promise.withResolvers() + pending.push(p) + return p.promise + } }, user, { onBeforeUnmount: (fn: () => void) => unmount = fn }) + page.startAt.value = new Date("2026-10-01T00:00:00Z") + const old = page.checkCoverage() + page.startAt.value = new Date("2026-10-02T00:00:00Z") + const current = page.checkCoverage() + pending[1].resolve({ total: 2 }) + await current + pending[0].resolve({ total: 1 }) + await old + assert.equal(page.coverage.value.total, 2) + const failed = page.checkCoverage() + pending[2].reject(new Error("核查失败")) + await failed + assert.equal(page.coverageError.value, true) + const retry = page.checkCoverage() + pending[3].resolve({ total: 3 }) + await retry + assert.equal(page.coverage.value.total, 3) + assert.equal(page.coverageError.value, false) + const inactive = page.checkCoverage() + unmount() + pending[4].resolve({ total: 4 }) + await inactive + assert.equal(page.coverage.value, null) + await page.checkCoverage() + assert.equal(pending.length, 5) + user.sessionVersion++ + await vue.nextTick() + assert.equal(page.coverage.value, null) + assert.equal(page.startAt.value, null) +}) + +test("切换会话后,旧回补核查不能恢复统计结果", async () => { + const pending = Promise.withResolvers() + const user = vue.reactive({ sessionVersion: 1 }) + const page = researchPage({ getCoverageApi: () => pending.promise }, user) + page.startAt.value = new Date("2026-10-01T00:00:00Z") + const old = page.checkCoverage() + user.sessionVersion++ + await vue.nextTick() + pending.resolve({ total: 999 }) + await old + assert.equal(page.coverage.value, null) + assert.equal(page.startAt.value, null) +}) + +test("回补时间无效或晚于当前时间时禁止启用", async () => { + let calls = 0 + const errors: string[] = [] + const page = researchPage({ enableApi: async () => calls++ }, undefined, { ElMessage: { error: (message: string) => errors.push(message) } }) + for (const value of [null, new Date("invalid"), new Date(Date.now() + 86400000)]) { + page.startAt.value = value + await page.enable() + } + assert.equal(calls, 0) + assert.equal(errors.length, 3) +}) + test("两阶段首页保留新查询结果,旧会话与卸载后的响应不写回", async () => { const pending: ReturnType[] = [] const user = vue.reactive({ sessionVersion: 1 })