diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css index ecdd0305c3..57a50af562 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css @@ -64,7 +64,7 @@ .research-question-index button:hover strong { text-decoration: underline; text-underline-offset: 3px; } .research-history-detail code { display: block; font-family: var(--font-mono); font-size: var(--text-body); overflow-wrap: anywhere; } .research-history-records { padding-left: 18px; font-size: var(--text-lead); line-height: var(--leading-prose); } -.research-history-records li { padding: 6px 0; } +.research-history-records > li { padding: 6px 0; } .research-history-records time { display: block; font-size: var(--text-caption); color: var(--pw-muted); } .research-history-loading { min-height: 240px; margin-top: 20px; padding: 24px; border: 1px solid var(--pw-line); border-radius: 12px; color: var(--pw-muted); background: var(--pw-bg); font-size: var(--text-body); } .research-history-loading-label { display: block; margin-bottom: 20px; } @@ -106,7 +106,7 @@ .research-comparison-controls > label { margin-inline-start: auto; color: var(--pw-text); } .research-record-search .context-help-trigger { min-height: 28px; padding: 4px; border: 0; border-radius: 4px; } .research-record-search .research-history-records { list-style: none; padding: 0; } -.research-record-search li { border-bottom: 1px solid var(--pw-line); } +.research-record-search > .research-history-records > li { border-bottom: 1px solid var(--pw-line); } .research-record-search summary { min-height: 44px; overflow-wrap: anywhere; } .research-record-search summary span { display: block; color: var(--pw-muted); font-size: var(--text-caption); } .research-record-entry:not([open]) > summary > strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } @@ -118,8 +118,8 @@ .research-record-sources { margin-block: 12px; } .research-record-search p { white-space: pre-wrap; line-height: var(--leading-prose); overflow-wrap: anywhere; } .research-record-search code { display: block; overflow-wrap: anywhere; font-size: var(--text-body); } -.research-record-search dd { margin: 0 0 8px; } -.research-record-search dt { color: var(--pw-muted); font-size: var(--text-caption); } +.research-record-sources dd { margin: 0 0 8px; } +.research-record-sources dt { color: var(--pw-muted); font-size: var(--text-caption); } .research-history-all-records { scroll-margin-top: 16px; } .research-record-search :is(input, select, summary):focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } .research-node-count { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -303,6 +303,8 @@ .research-comparison-pair pre { white-space: pre-wrap; } .research-comparison-identities p { margin-block: 4px 12px; } .research-comparison-field { border-block-start: 1px solid var(--pw-line); padding-block: 12px; } +.research-comparison-field .research-finding-sections { grid-template-columns: max-content minmax(0, 1fr); gap: 8px 12px; margin-block: 8px; } +.research-comparison-field .research-finding-sections dd { margin: 0; } .research-comparison-side, .research-comparison-missing { color: var(--pw-muted); } .research-comparison-side { display: none; } @media (max-width: 640px) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 30c93d4964..b074ecba8a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -119,18 +119,21 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const sectionCopy = zh ? {inputRevision: "输入版本", applicability: "适用范围", observation: "观察", interpretation: "解读", changed: "变化", stopped: "停止", retained: "保留"} : {inputRevision: "Input revision", applicability: "Applicability", observation: "Observation", interpretation: "Interpretation", changed: "Changed", stopped: "Stopped", retained: "Retained"}; const outcomeCopy: Record = zh ? {replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} : {replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human"}; - const findingBody = (finding: ExploreHistoryEvent) => { + const findingNarrative = (finding: ExploreHistoryEvent) => { const sections = findingSections(finding); const decision = sections?.decision; - const execution = finding.execution_observation; - const measured = (value: unknown) => value === null ? (zh ? "未测量" : "Not measured") : String(value); - return <>{finding.measurements ? : null}{sections ?
+ return sections ?
{sectionCopy.observation}
{sections.observation}
{decision ? <>
{zh ? "决策" : "Decision"}
{outcomeCopy[decision.outcome] ?? decision.outcome}
{(["changed", "stopped", "retained"] as const).map(key => decision[key].length ?
{sectionCopy[key]}
    {decision[key].map(item =>
  • {item}
  • )}
: null)} : <>
{sectionCopy.interpretation}
{sections.interpretation}
} -
:

{finding.summary}

}{execution ?
{zh ? "实际执行与资源观测" : "Execution and resource observations"} +
:

{finding.summary}

; + }; + const findingBody = (finding: ExploreHistoryEvent) => { + const execution = finding.execution_observation; + const measured = (value: unknown) => value === null ? (zh ? "未测量" : "Not measured") : String(value); + return <>{finding.measurements ? : null}{findingNarrative(finding)}{execution ?
{zh ? "实际执行与资源观测" : "Execution and resource observations"}
{zh ? "计划方法 → 实际方法" : "Planned → executed method"}
{String(execution.planned_method_id)} → {String(execution.executed_method_id)}
{execution.change_reason ? <>
{zh ? "改向原因" : "Reason for change"}
{String(execution.change_reason)}
: null} @@ -542,6 +545,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re displayEvents={currentDisplay && !originalDisplay ? value.events : undefined} displaySections={currentDisplay && !originalDisplay ? displayProjection.sections : undefined} renderBody={currentDisplay && !originalDisplay ? findingBody : undefined} + renderSummary={currentDisplay && !originalDisplay ? findingNarrative : undefined} complete={read.complete} stale={Boolean(error || history.error)} busy={busy} zh={zh} stateLabel={stateLabel} timeLabel={timeLabel}/> : null}
: null} ; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx index 70b3001352..9c6c3f88fb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx @@ -8,10 +8,11 @@ import {exportResearchRecords} from "./research-record-export"; import {downloadReport} from "./report-download"; /** Local read controls over the existing validated history snapshot. */ -export function ResearchRecordSearch({goalId, events, displayEvents, displaySections, renderBody, complete, stale, busy, zh, stateLabel, timeLabel}: { +export function ResearchRecordSearch({goalId, events, displayEvents, displaySections, renderBody, renderSummary, complete, stale, busy, zh, stateLabel, timeLabel}: { goalId: string; displayEvents?: readonly ExploreHistoryEvent[]; renderBody?: (event: ExploreHistoryEvent) => ReactNode; displaySections?: ResearchRecordDisplay["sections"]; + renderSummary?: (event: ExploreHistoryEvent) => ReactNode; events: readonly ExploreHistoryEvent[]; complete: boolean; stale: boolean; busy: boolean; zh: boolean; stateLabel: (status: string) => string; timeLabel: (at: string) => string; }) { @@ -83,7 +84,8 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect {event.evidence_refs.map(ref => {ref})} - + ;})} {limit < matches.length ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx index 3faf326798..0a2266ff76 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useState, type ReactNode} from "react"; import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; import {changedResearchFields, researchComparisonFields, researchRecordVersions, type ResearchComparisonField} from "./research-record-versions.ts"; import {ExploreMeasurements} from "./explore-measurements"; @@ -15,8 +15,9 @@ const labels: Record = { }; /** Requested original-source detail; no analysis, acceptance or write path. */ -export function ResearchRecordComparison({events, selected, zh, stateLabel, timeLabel}: { +export function ResearchRecordComparison({events, selected, displayed, renderSummary, zh, stateLabel, timeLabel}: { events: readonly ExploreHistoryEvent[]; selected: ExploreHistoryEvent; zh: boolean; + displayed?: ReadonlyMap; renderSummary?: (event: ExploreHistoryEvent) => ReactNode; stateLabel: (status: string) => string; timeLabel: (at: string) => string; }) { const versions = researchRecordVersions(events, selected); @@ -31,18 +32,24 @@ export function ResearchRecordComparison({events, selected, zh, stateLabel, time const changed = changedResearchFields(selected, comparison); const value = (event: ExploreHistoryEvent, field: ResearchComparisonField) => { const raw = event[field]; + const shown = displayed?.get(event.event_id) ?? event; if (raw === undefined || raw === null || raw === "" || (Array.isArray(raw) && !raw.length)) return {zh ? "未记录" : "Not recorded"}; if (field === "measurements") return ; if (field === "status") return stateLabel(event.status); if (Array.isArray(raw)) return
    {raw.map((item, i) =>
  • {String(item)}
  • )}
; - if (typeof raw === "object") return
{JSON.stringify(raw, null, 2)}
; - return String(raw); + const narrative = field === "summary" && renderSummary; + const content = narrative ? renderSummary(shown) + : typeof raw === "object" ?
{JSON.stringify(shown[field], null, 2)}
: String(shown[field]); + return <>{content}{narrative || JSON.stringify(shown[field]) !== JSON.stringify(raw) ?
+ {zh ? "完整原文" : "Full original text"} + {typeof raw === "object" ?
{JSON.stringify(raw, null, 2)}
:

{String(raw)}

} +
: null}; }; return
setOpen(event.currentTarget.open)}> {zh ? "比较记录版本" : "Compare recorded versions"} · {versions.length} {open ? <>
{zh ? `${changed.length} 个字段不同` : `${changed.length} fields differ`} @@ -57,7 +64,7 @@ export function ResearchRecordComparison({events, selected, zh, stateLabel, time {[selected, comparison].map((event, i) =>
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}

{timeLabel(event.recorded_at)}

)}
{(showAll ? researchComparisonFields : changed).map(field =>
-

{labels[field][zh ? 1 : 0]}

+

{field === "summary" && renderSummary ? (zh ? "记录正文" : "Recorded text") : labels[field][zh ? 1 : 0]}

{[selected, comparison].map((event, i) =>
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}
{value(event, field)}
diff --git a/docs/development/design.md b/docs/development/design.md index 5cfdfc6bd1..5656afb3cd 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -233,9 +233,15 @@ When an explicit analysis supplies a whole-history translation, use it throughou the matching research snapshot, including routes, decisions and expanded history. Offer one in-place original/translation switch; keep coverage and source/version detail in contextual help, with incomplete or inapplicable coverage visible. +Version comparison uses that same qualified field projection for both records, +while lineage and change counts still compare original records. Keep both +originals readable in place; a translation must not create a source change. Keep original evidence directly readable. A language switch only reads; missing or failed translations retain original text and never silently launch a model. +版本比较复用两条记录已核对的同一快照译文;版本关系与差异数仍按原始记录计算, +两侧原文可原位展开,不把译文变化算作来源变化。 + Review the displayed translation against its original and cited context, including quantities, their referents, negation and uncertainty. Field coverage and preserved literals do not establish readable meaning. When the source supports a quantity, @@ -251,6 +257,7 @@ existing task and review. 的连写英文;准确原词仍可查,含义不明的计数、单位和标识不猜测。修订沿版本化分析 处理,评审不自动重跑模型或覆盖已存结果;剩余正文问题记入原任务和评审。 + 刷新和前进后退保留视图、时间分组与证据焦点;标签之间保留阅读位置。选择状态按 Goal 和来源隔离。研究中从侧栏切换 Goal 仍停留在研究页,读取新 Goal 自身证据; 返回恢复原 Goal 的选择。首页及任务、会话、成果的明确链接保留各自目的地。 diff --git a/examples/personal-workspace-browser/evidence/research-version-language/README.md b/examples/personal-workspace-browser/evidence/research-version-language/README.md new file mode 100644 index 0000000000..ae78ee1ffa --- /dev/null +++ b/examples/personal-workspace-browser/evidence/research-version-language/README.md @@ -0,0 +1,26 @@ +# Research version language + +Synthetic records served by the packaged Chat frontend and the disposable +production SQLite/history/analysis HTTP owners. No model or solver runs. +These captures show renderer behavior, not installed-App adoption or independently +qualified translation meaning. + +- Rendered product source: `4a49f62f0eb5ac74c524ac26155f03d3c47c2433`. +- The rendered source includes the current-main translated-search integration; + the evidence refresh changes only this scenario and its captures. +- Before: `fb0303f33327c51da434a215f0c757b5d1a5b16a`. +- Both views use the same 31-event fixed snapshot, including two revisions of + one finding, a refuted observation and an unverified follow-up. +- `first-*` is the complete first viewport; `before-*` and `after-*` show the + requested comparison on desktop and at 390px. +- After: comparison reuses the saved field translations; original records still + determine the version pair and four changed fields. Full originals remain + available in place. +- `partial-*` intentionally omits a field and retains its original text. + The existing translation-coverage indicator stays available in the page header. + +The existing `research-display-language` browser scenario recreates these views +and verifies originals, exact comparison selection, unchanged source-difference +counts, invalid/missing/source/language fallback, citation return and read-only +behavior. Its existing explicit execution-refusal case makes one rejected POST; +ordinary reading never executes analysis. diff --git a/examples/personal-workspace-browser/evidence/research-version-language/after-1440.png b/examples/personal-workspace-browser/evidence/research-version-language/after-1440.png new file mode 100644 index 0000000000..67702ea7c8 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/after-1440.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/after-390.png b/examples/personal-workspace-browser/evidence/research-version-language/after-390.png new file mode 100644 index 0000000000..b1597f7d0c Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/after-390.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/before-1440.png b/examples/personal-workspace-browser/evidence/research-version-language/before-1440.png new file mode 100644 index 0000000000..7b80e8091a Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/before-1440.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/before-390.png b/examples/personal-workspace-browser/evidence/research-version-language/before-390.png new file mode 100644 index 0000000000..0745510c37 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/before-390.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/first-1440.png b/examples/personal-workspace-browser/evidence/research-version-language/first-1440.png new file mode 100644 index 0000000000..80dd0c285e Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/first-1440.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/first-390.png b/examples/personal-workspace-browser/evidence/research-version-language/first-390.png new file mode 100644 index 0000000000..760592f6a4 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/first-390.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/partial-1440.png b/examples/personal-workspace-browser/evidence/research-version-language/partial-1440.png new file mode 100644 index 0000000000..11480c56c7 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/partial-1440.png differ diff --git a/examples/personal-workspace-browser/evidence/research-version-language/partial-390.png b/examples/personal-workspace-browser/evidence/research-version-language/partial-390.png new file mode 100644 index 0000000000..f4e4e995b7 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-version-language/partial-390.png differ diff --git a/examples/personal-workspace-browser/research-display-language.mjs b/examples/personal-workspace-browser/research-display-language.mjs index 7a3c04634c..ad8b315331 100644 --- a/examples/personal-workspace-browser/research-display-language.mjs +++ b/examples/personal-workspace-browser/research-display-language.mjs @@ -26,6 +26,9 @@ for i in range(20): events.append(build_explore_finding_event(goal_id=goal,node_id=node,finding_id=f'finding-{i}',title='Negative evidence, not acceptance', summary='Input revision: fixed-v1 Applicability: Fixed source only; the later attempt remains unverified. Observation: The measured change was -0.25ms and does not establish causal benefit. Trial4failed2checks; local-only evidence remains preliminary. Interpretation: Outcome: replan retained: - Keep V18 changed: - Check the original evidence stopped: - Never claim native success', status='refuted' if i%4==0 else 'tentative',recorded_at=f'2026-01-01T{i:02}:30:00Z')) +events.append(build_explore_finding_event(goal_id=goal,node_id='question-0',finding_id='finding-0', + title='Later evidence remains unverified',summary=events[1]['summary'].replace('fixed-v1','fixed-v2').replace('-0.25ms','-0.50ms'), + status='tentative',supersedes=events[1]['event_id'],recorded_at='2026-01-01T19:45:00Z')) Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成研究 · 展示语言','events':events},ensure_ascii=False)) `, path], {cwd: repoRoot}); const capture = JSON.parse(await readFile(path, "utf8")); @@ -50,6 +53,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成 const translate = row => row.text.replaceAll("Does ", "").replaceAll(" improve native utility under the fixed budget?", " 是否在固定预算内改善原生收益?") .replaceAll("Not established; no native validation yet.", "尚未确定;尚无原生验证。") .replaceAll("Negative evidence, not acceptance", "负向证据,尚未通过验收") + .replaceAll("Later evidence remains unverified", "后续证据,仍未核验") .replaceAll("Fixed source only; the later attempt remains unverified.", "仅适用于固定来源;后续尝试仍未核验。") .replaceAll("The measured change was ", "实测变化为 ").replaceAll(" and does not establish causal benefit.", ",尚不能证明因果收益。") .replace(/(⟪LX\d+⟫); (⟪LX\d+⟫) evidence remains preliminary\./g, (original, trial, scope) => @@ -68,7 +72,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成 const outputs = Object.fromEntries(["zh", "en"].map(language => { const p = prepareTrajectoryAssessment({input, config: {model: "gpt-6.1-sol", effort: "max", service_tier: "fast", output_language: language}}); let rows = JSON.parse(p.prompt.split("\n\ndisplay_fields:\n")[1]).map(row => ({field_id: row.field_id, text: language === "zh" ? translate(row) : row.text})); - if (variant === "partial") rows = [{...rows[0], text: "999"}, ...rows.slice(2)]; + if (variant === "partial") rows = [{...rows[0], text: "999"}, ...rows.slice(2, -1)]; return [language, {assessments: TRAJECTORY_METRICS.flatMap(metric => names.map((episode_id, index) => ({episode_id, metric, verdict: index < 3 ? "unknown" : index === 3 ? "no" : "yes", reason: reasons[language][index] ?? (language === "zh" ? "合成证据;负面结果并未证明因果收益,原生效果仍未核验。" : "Synthetic evidence; a negative result does not establish causal benefit. Native effectiveness remains unverified."), @@ -162,9 +166,9 @@ export const researchDisplayLanguageScenario = { const question = reader.locator(".research-reader-question"); assert(await question.evaluate(element => element.open), "The route's complete question opens with its finding"); assert.match(await question.innerText(), /V18 是否在固定预算/); - assert.match(await reader.innerText(), /实测变化为 -0.25ms/); + assert.match(await reader.innerText(), /实测变化为 -0.50ms/); assert.match(await reader.innerText(), /第4次尝试有2项检查未通过(Trial4failed2checks);仅限本地(local-only)的证据仍属初步/); - assert.match(await reader.innerText(), /已反驳/); + assert.match(await reader.innerText(), /暂定结论/); assert(!await reader.locator(".research-finding-sections").first().innerText().then(text => text.includes("Input revision:"))); const provenance = reader.locator('.research-node-provenance'); await provenance.locator(':scope > summary').click(); @@ -202,11 +206,16 @@ export const researchDisplayLanguageScenario = { await search.fill("V18 是否在固定预算"); await allRecords.evaluate(element => element.scrollIntoView({block: "start"})); await page.screenshot({path: resolve(outputDir, `research-translated-search-title-${width}.png`), animations: "disabled"}); - await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor(); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 1 / ${capture.events.length} 条已读记录`)}).waitFor(); assert.equal(await allRecords.locator(".research-history-records > li").count(), 1, "Displayed Chinese titles are searchable"); const finding = capture.events.find(event => event.event_kind === "finding"); await search.fill(`第4次尝试 ${finding.event_id}`); - await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor(); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 2 / ${capture.events.length} 条已读记录`)}).waitFor(); + const lineageIds = [finding.event_id, capture.events.at(-1).event_id]; + assert.deepEqual((await downloadRecords()).records, originalsExport.records.filter(event => lineageIds.includes(event.event_id)), + "The original id also matches its explicit successor lineage; downloads retain both original records"); + await search.fill(`第4次尝试 ${finding.event_id} fixed-v1`); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 1 / ${capture.events.length} 条已读记录`)}).waitFor(); const translatedFinding = allRecords.locator(".research-history-records > li").first(); await translatedFinding.locator(":scope > details > summary").click(); assert.match(await translatedFinding.innerText(), /实测变化为 -0.25ms/); @@ -214,13 +223,13 @@ export const researchDisplayLanguageScenario = { assert.deepEqual(exported.records, originalsExport.records.filter(event => event.event_id === finding.event_id), "Chinese matching downloads the original event, not translated prose"); await page.screenshot({path: resolve(outputDir, `research-translated-search-body-${width}.png`), animations: "disabled"}); - await search.fill(`核对原始证据 ${finding.event_id}`); - await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor(); + await search.fill(`核对原始证据 ${finding.event_id} fixed-v1`); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 1 / ${capture.events.length} 条已读记录`)}).waitFor(); await original.click(); - await allRecords.getByRole("status", {name: /^匹配 0 \/ 30 条已读记录/}).waitFor(); - assert.equal(await search.inputValue(), `核对原始证据 ${finding.event_id}`, "Original view retains the query but removes translation-only matches"); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 0 / ${capture.events.length} 条已读记录`)}).waitFor(); + assert.equal(await search.inputValue(), `核对原始证据 ${finding.event_id} fixed-v1`, "Original view retains the query but removes translation-only matches"); await history.getByRole("button", {name: "查看译文", exact: true}).click(); - await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor(); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 1 / ${capture.events.length} 条已读记录`)}).waitFor(); await search.fill(capture.events[0].title); assert.equal(await allRecords.locator(".research-history-records > li").count(), 1, "Search matches original text while translations are displayed"); const record = allRecords.locator(".research-history-records > li").first(); @@ -235,6 +244,40 @@ export const researchDisplayLanguageScenario = { await history.getByRole("button", {name: "查看译文", exact: true}).click(); await allRecords.getByRole("button", {name: "清除筛选", exact: true}).click(); assert.equal(await allRecords.locator(".research-history-records > li").count(), capture.events.length); + await search.fill("finding-0"); + assert.equal(await allRecords.locator(".research-history-records > li").count(), 2); + const revision = allRecords.locator(".research-record-entry").last(); + await revision.locator(":scope > summary").click(); + const comparison = revision.locator(".research-record-comparison"); + const compareEntry = comparison.locator(":scope > summary"); + await compareEntry.focus(); await compareEntry.press("Enter"); + const comparisonText = comparison.locator(".research-comparison-field").nth(1); + await comparisonText.waitFor(); + await comparisonText.evaluate(element => element.scrollIntoView({block: "center"})); + await page.screenshot({path: resolve(outputDir, `research-display-version-comparison-${width}.png`), animations: "disabled"}); + assert.match(await comparisonText.innerText(), /实测变化为 -0\.50ms/, + "Version comparison uses the same qualified snapshot translation as the history reader"); + assert.match(await comparisonText.innerText(), /实测变化为 -0\.25ms/); + assert.match(await comparisonText.innerText(), /尚不能证明因果收益/); + assert.match(await comparison.innerText(), /已反驳/); + const comparisonIds = await comparison.getByRole("combobox").inputValue(); + const changedCount = await comparison.getByRole("status").innerText(); + const sourceTexts = capture.events.filter(event => event.result_id === "finding-0").map(event => event.summary); + const originalTexts = comparisonText.locator("details > summary"); + assert.equal(await originalTexts.count(), 2); + await originalTexts.first().click(); + await comparisonText.getByText(sourceTexts[1], {exact: true}).waitFor(); + await originalTexts.first().click(); + await original.click(); + assert.match(await comparisonText.innerText(), /The measured change was -0\.50ms/); + assert.equal(await comparison.getByRole("combobox").inputValue(), comparisonIds, + "Original/translation toggles retain the exact comparison pair"); + assert.equal(await comparison.getByRole("status").innerText(), changedCount, + "Translation changes never alter the count of changed source fields"); + await history.getByRole("button", {name: "查看译文", exact: true}).click(); + await compareEntry.focus(); await compareEntry.press("Enter"); + assert(await compareEntry.evaluate(element => element === document.activeElement)); + await allRecords.getByRole("button", {name: "清除筛选", exact: true}).click(); await allRecords.locator(":scope > summary").click(); await history.getByRole("button", {name: "查看分析", exact: true}).click(); const analysis = history.getByRole("region", {name: "轨迹深度分析", exact: true}); @@ -281,6 +324,19 @@ export const researchDisplayLanguageScenario = { await scope.selectOption({label: "部分译文"}); await history.locator(".research-display-language > span").filter({hasText: /译文 \d+ \/ \d+/}).waitFor(); assert.match(await history.locator(".research-route-caption").first().innerText(), /Does V18/, "Missing/invalid title remains original"); + await allRecords.locator(":scope > summary").click(); + await allRecords.getByRole("searchbox").fill("finding-0"); + const partialComparison = allRecords.locator(".research-record-entry").last(); + await partialComparison.locator(":scope > summary").click(); + await partialComparison.locator(".research-record-comparison > summary").click(); + const partialText = partialComparison.locator(".research-comparison-field").nth(1); + await partialText.waitFor(); + assert.match(await partialText.innerText(), /Keep V18/, + "A missing translation retains the exact source item beside translated fields"); + assert.match(await partialText.innerText(), /保留 V18/); + await partialText.evaluate(element => element.scrollIntoView({block: "center"})); + await page.screenshot({path: resolve(outputDir, `research-display-version-partial-${width}.png`), animations: "disabled"}); + await allRecords.locator(":scope > summary").click(); if (width === 1440) { const before = await fetch(`${owner.base}/fixture/readback`).then(response => response.json()); assert.equal(before.mutation_attempts, 0, "Reading, language and original toggles never execute"); @@ -297,9 +353,9 @@ export const researchDisplayLanguageScenario = { assert.match(await history.locator(".research-route-caption").first().innerText(), /Does V18/); await allRecords.locator(":scope > summary").click(); await search.fill("V18 是否在固定预算"); - await allRecords.getByRole("status", {name: /^匹配 0 \/ 30 条已读记录/}).waitFor(); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 0 / ${capture.events.length} 条已读记录`)}).waitFor(); await search.fill(capture.events[0].title); - await allRecords.getByRole("status", {name: /^匹配 1 \/ 30 条已读记录/}).waitFor(); + await allRecords.getByRole("status", {name: new RegExp(`^匹配 1 / ${capture.events.length} 条已读记录`)}).waitFor(); assert.equal(await allRecords.locator(".research-history-records > li strong").first().innerText(), capture.events[0].title, "A mismatched source cannot retain the previous translation's search aliases"); await allRecords.locator(":scope > summary").click();