From c90691d9a0fc897c6a8961a610f446712579b7ec Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 6 Nov 2026 12:49:20 +0800 Subject: [PATCH] Add configurable news research backfill and coverage reporting Allow selecting a historical start time and checking unfinished news before enabling analysis. Report enrolled work outside the selected range and use the active server boundary while retaining paused drafts. Cover date validation, queue carryover, task completion and cross-tab responses. --- backend/src/alphapulse/news_research/api.py | 24 +++- .../src/alphapulse/news_research/batches.py | 41 +++++- backend/tests/test_news_research_workspace.py | 48 +++++++ scripts/smoke_news_batches.py | 106 ++++++++++++++ scripts/smoke_news_research.py | 18 ++- web/src/common/apis/news-research/index.ts | 3 +- web/src/common/apis/news-research/type.ts | 1 + web/src/pages/home/index.vue | 88 +++++++++++- web/tests/workspaces.test.ts | 132 +++++++++++++++++- 9 files changed, 451 insertions(+), 10 deletions(-) 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 })