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 8e06134e73..ecdd0305c3 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 @@ -102,11 +102,20 @@ .research-record-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 12px; } .research-record-filters input { flex: 1 1 220px; min-width: 0; } .research-record-filters input, .research-record-filters select, .research-record-search button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; color: var(--pw-text); background: var(--pw-bg); font: inherit; } -.research-record-search > p { color: var(--pw-muted); } +.research-record-count, .research-comparison-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; margin-block: 12px; color: var(--pw-muted); font-size: var(--text-body); } +.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 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; } +.research-record-entry[open] > summary > strong { display: block; line-height: var(--leading-prose); } +.research-record-entry > summary { position: relative; padding-inline-start: 18px; list-style: none; } +.research-record-entry > summary::-webkit-details-marker { display: none; } +.research-record-entry > summary::before { content: "▸"; position: absolute; inset-inline-start: 0; } +.research-record-entry[open] > summary::before { content: "▾"; } +.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; } @@ -292,10 +301,12 @@ .research-comparison-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } .research-comparison-pair > div { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } .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-side, .research-comparison-missing { color: var(--pw-muted); } .research-comparison-side { display: none; } @media (max-width: 640px) { .research-comparison-pair { grid-template-columns: 1fr; gap: 16px; } + .research-comparison-identities { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .research-comparison-side { display: block; margin-block-end: 4px; } } 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 2afc3b3ab5..03bc5f7090 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 @@ -1,5 +1,5 @@ import {ResearchRecordComparison} from "./research-record-comparison"; -import {CircleHelp} from "lucide-react"; +import {CircleHelp, TriangleAlert} from "lucide-react"; import {ContextHelp} from "./context-help"; import {useMemo, useState, type ReactNode} from "react"; import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; @@ -21,6 +21,8 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody, const matches = useMemo(() => searchResearchRecords(events, query, status), [events, query, status]); const displayed = useMemo(() => new Map(displayEvents?.map(event => [event.event_id, event])), [displayEvents]); const statuses = [...new Set(events.map(event => event.status).filter(Boolean))].sort(); + const countLabel = zh ? `匹配 ${matches.length} / ${events.length} 条已读记录` : `${matches.length} matches in ${events.length} loaded records`; + const readState = busy ? (zh ? "读取中" : "Reading") : stale ? (zh ? "刷新失败" : "Refresh failed") : ""; const reset = () => {setQuery(""); setStatus(""); setLimit(50);}; return
@@ -42,22 +44,32 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody, }}>{zh ? "下载匹配记录(JSON)" : "Download matches (JSON)"}
{downloadFailed ?

{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}

: null} -

{zh ? `匹配 ${matches.length} / ${events.length} 条已读记录` : `${matches.length} matches in ${events.length} loaded records`} - {!complete ? (zh ? " · 历史不完整" : " · History incomplete") : ""} - {busy ? (zh ? " · 正在读取历史" : " · Reading history") : stale ? (zh ? " · 刷新失败,显示上次读取的记录" : " · Refresh failed; showing previously read records") - : ""}

- {!matches.length ?

{complete && !stale && !busy - ? (zh ? "没有匹配的记录。尝试其他文字或清除筛选。" : "No matching records. Try another term or clear the filters.") - : (zh ? "已读部分没有匹配记录;其余历史尚未核验。" : "No matches in the loaded records; the remaining history is not verified.")}

: null} +
+ + {matches.length} / {events.length} {zh ? "条记录" : "records"}{readState ? ` · ${readState}` : ""} + + +

{countLabel}{zh ? "。" : "."}

+

{complete ? (zh ? "已搜索完整载入的历史。" : "Search covers the complete loaded history.") : (zh ? "历史不完整;其余记录尚未核验。" : "History is incomplete; remaining records have not been verified.")}

+ {stale ?

{zh ? "刷新失败,显示上次读取的记录。" : "Refresh failed; showing previously read records."}

: null} + {!matches.length ?

{complete && !stale && !busy + ? (zh ? "没有匹配的记录。尝试其他文字或清除筛选。" : "No matching records. Try another term or clear the filters.") + : (zh ? "已读部分没有匹配记录;其余历史尚未核验。" : "No matches in the loaded records; the remaining history is not verified.")}

: null} +
+
    {matches.slice(0, limit).map(event => { const shown = displayed.get(event.event_id) ?? event; return
  1. -
    {shown.title}{timeLabel(event.recorded_at)} · {stateLabel(event.status)} +
    {shown.title}{timeLabel(event.recorded_at)} · {stateLabel(event.status)} {renderBody ? renderBody(shown) : event.summary ?

    {event.summary}

    : null} {shown.blocked_reason ?

    {shown.blocked_reason}

    : null} {renderBody ?
    {zh ? "完整原文" : "Full original text"}

    {event.title}

    {event.summary}

    {event.blocked_reason ?

    {event.blocked_reason}

    : null}
    : null} +
    {zh ? "来源与标识" : "Sources and identity"}
    {zh ? "记录 ID" : "Record ID"}
    {event.event_id}
    {zh ? "结果 ID" : "Result ID"}
    {event.result_id}
    {event.run_id ? <>
    {zh ? "运行 ID" : "Run ID"}
    {event.run_id}
    : null} @@ -67,8 +79,9 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody, {event.from_node || event.to_node ? <>
    {zh ? "显式关联" : "Recorded link"}
    {event.from_node} → {event.to_node}
    : null} {event.agent_id ? <>
    Agent ID
    {event.agent_id}
    : null}
    - {event.evidence_refs.map(ref => {ref})} +
    +
  2. ;})}
{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 c78d987eb2..3faf326798 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 @@ -2,6 +2,7 @@ import {useState} 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"; +import {ContextHelp} from "./context-help"; const labels: Record = { title: ["Title", "标题"], summary: ["Original text", "原文"], status: ["Recorded status", "记录状态"], @@ -43,12 +44,18 @@ export function ResearchRecordComparison({events, selected, zh, stateLabel, time -

{zh ? "比较原始记录,不代表同条件评测或研究质量变化。" : "Compares original records, not matched evaluations or changes in research quality."}

-
- {[selected, comparison].map((event, i) =>
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}

{timeLabel(event.recorded_at)}

{event.event_id}
)} +
+ {zh ? `${changed.length} 个字段不同` : `${changed.length} fields differ`} + +

{zh ? "比较原始记录,不代表同条件评测或研究质量变化。" : "Compares original records, not matched evaluations or changes in research quality."}

+

{zh ? "所选记录" : "Selected record"}{selected.event_id}

+

{zh ? "对照记录" : "Comparison record"}{comparison.event_id}

+
+ +
+
+ {[selected, comparison].map((event, i) =>
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}

{timeLabel(event.recorded_at)}

)}
-

{zh ? `${changed.length} 个字段不同` : `${changed.length} fields differ`}

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

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

{[selected, comparison].map((event, i) =>
diff --git a/docs/development/design.md b/docs/development/design.md index 2d1ddc1b29..b4adafdc09 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -219,6 +219,16 @@ action consequences or relying on fixed alignment offsets. Reading a citation opens only the requested original, preserving the explicit open or closed state of the full analysis and returning to the exact citation. +Search results use the same reading hierarchy: compact title, recorded time and +status in the list; opening a row restores its complete title and body. Keep +protocol identities and source references in one disclosure, with version +comparison beside it. Counts stay compact; coverage and comparison-method +explanations use contextual help. Reading failures retain a visible state and +the existing recovery action. Do not clamp the opened document or its evidence. + +搜索列表以标题、时间和记录状态供浏览,展开后完整呈现正文。来源与标识合并按需查看, +版本比较保留原位入口;范围、口径用共享帮助,真实读取失败和恢复动作仍可见。 + When an explicit analysis supplies a whole-history translation, use it throughout the matching research snapshot, including routes, decisions and expanded history. Offer one in-place original/translation switch; keep coverage and source/version diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/README.md b/examples/personal-workspace-browser/evidence/research-record-reading/README.md new file mode 100644 index 0000000000..98898466d1 --- /dev/null +++ b/examples/personal-workspace-browser/evidence/research-record-reading/README.md @@ -0,0 +1,19 @@ +# Research record reading + +Public-safe synthetic data: 79 recorded events and one separate single-record +Goal, served by the packaged Chat frontend and disposable production history +backend. These are presentation and recovery checks, not scientific results or +installed-App adoption. + +- Before: `cd819fd206cd9de46a789df0669e13bb2ed763e5`. +- List views use the same long, mixed-language records before and after. +- Record views retain the same negative observation and its three revisions. +- Source identities remain readable on demand; the Chinese view retains original + evidence text in its original language. +- Comparison views preserve unmeasured versus measured zero and both originals. +- `first-desktop.png` shows the complete first viewport before opening search. + +The PR description records the tested candidate revision and validation. The +existing `research-record-search` scenario recreates the captures and checks +paging, filtered downloads, keyboard expansion, help, partial/failed reads, +recovery and Goal isolation without research execution or source mutations. diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/after-list-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/after-list-phone.png new file mode 100644 index 0000000000..553961605b Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/after-list-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/after-record-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/after-record-phone.png new file mode 100644 index 0000000000..0eb7c39230 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/after-record-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/before-list-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/before-list-phone.png new file mode 100644 index 0000000000..55ed36e8b8 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/before-list-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/before-record-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/before-record-phone.png new file mode 100644 index 0000000000..3350cb0a5b Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/before-record-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/comparison-desktop.png b/examples/personal-workspace-browser/evidence/research-record-reading/comparison-desktop.png new file mode 100644 index 0000000000..13d70c6847 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/comparison-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/comparison-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/comparison-phone.png new file mode 100644 index 0000000000..59e894851a Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/comparison-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/first-desktop.png b/examples/personal-workspace-browser/evidence/research-record-reading/first-desktop.png new file mode 100644 index 0000000000..6fb5d6abb2 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/first-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-record-reading/sources-phone.png b/examples/personal-workspace-browser/evidence/research-record-reading/sources-phone.png new file mode 100644 index 0000000000..bdf82ba7fc Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-record-reading/sources-phone.png differ diff --git a/examples/personal-workspace-browser/research-record-search.mjs b/examples/personal-workspace-browser/research-record-search.mjs index 671aef5deb..c16a2d4233 100644 --- a/examples/personal-workspace-browser/research-record-search.mjs +++ b/examples/personal-workspace-browser/research-record-search.mjs @@ -29,7 +29,7 @@ goal='synthetic-record-search' events=[build_explore_node_event(goal_id=goal,node_id='question',title='Synthetic candidate comparison',recorded_at='2026-01-01T00:00:00Z')] for index in range(75): events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id=f'finding-{index}', - title=f'Candidate {index}',summary='Original observation; complete costs remain unknown.', + title=f'Candidate {index} — '+('研究问题与完整原始结论 / original question and retained evidence. '*3)+'END OF ORIGINAL TITLE',summary='Original observation; complete costs remain unknown.', status='tentative',recorded_at='2026-01-01T01:00:00Z')) measurement=dict(metric='Synthetic score', unit='points', higher_is_better=True, comparison_scope='synthetic:fixed', input_revision='input:v1', evaluation_ref='method:v1', candidate_ref='candidate:amber', evidence_ref='result:amber') events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id='retained-finding', @@ -53,6 +53,8 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s const url = `${owner.base}/chat/?goalId=synthetic-record-search&view=research&statusUrl=%2Fstatus.json`; await page.goto(url); const history = page.locator(".research-history"); + await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false'); + await page.screenshot({path: resolve(outputDir, "research-record-first-1440.png"), animations: "disabled"}); await history.getByRole("button", {name: "Search research records", exact: true}).click(); const search = history.getByRole("searchbox", {name: "Search research records"}); const results = history.locator(".research-record-search"); @@ -68,23 +70,56 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert(await search.evaluate(element => element === document.activeElement), "The search entry moves keyboard focus to the input"); await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false'); assert.equal(await results.locator("li").count(), 50); + // Long mixed-language records remain scannable; one keyboard action restores + // the complete title and body without exposing source diagnostics. + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 900}); + await results.evaluate(element => element.scrollIntoView({block: "start"})); + await page.screenshot({path: resolve(outputDir, `research-record-list-${width}.png`), animations: "disabled"}); + } + const longRecord = results.locator(".research-history-records > li > details").nth(1); + const longTitle = longRecord.locator(":scope > summary > strong"); + assert(await longTitle.evaluate(element => element.clientHeight < element.scrollHeight), "Collapsed long titles leave room for other records"); + const longSummary = longRecord.locator(":scope > summary"); + await longSummary.focus(); + await page.keyboard.press("Enter"); + assert.match(await longRecord.innerText(), /END OF ORIGINAL TITLE/); + assert(await longTitle.evaluate(element => element.clientHeight >= element.scrollHeight), "The opened title is complete at 390px"); + assert.doesNotMatch(await longRecord.innerText(), /Record ID|Result ID/); + await page.screenshot({path: resolve(outputDir, "research-record-long-390.png"), animations: "disabled"}); + await page.keyboard.press("Enter"); + assert(await longSummary.evaluate(element => element === document.activeElement), "Closing preserves the exact record entry"); + const scopeHelp = results.getByRole("button", {name: /[Ss]earch scope/}); + await scopeHelp.focus(); + await page.getByRole("tooltip").getByText("Search covers the complete loaded history.", {exact: true}).waitFor(); + await page.keyboard.press("Escape"); + assert(await scopeHelp.evaluate(element => element === document.activeElement)); + await scopeHelp.click(); + await page.getByRole("tooltip").getByText("Search covers the complete loaded history.", {exact: true}).waitFor(); + await page.keyboard.press("Escape"); + await page.setViewportSize({width: 1440, height: 900}); const all = await download(); assert.equal(all.goal_id, "synthetic-record-search"); assert.equal(all.records.length, 79, "Download includes matches beyond both HTTP and display paging"); assert.deepEqual(all.coverage, {loaded_records: 79, exported_records: 79, history_complete: true, refresh_failed: false}); await search.fill("AMBER-V1 run:old"); - await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); assert.equal(await results.locator("li").count(), 1, "Search reaches beyond both the display limit and HTTP page boundary"); await results.locator(".research-history-records > li > details > summary").click(); assert.match(await results.locator("li").innerText(), /质量下降.*original negative evidence/); - assert.match(await results.locator("li").innerText(), /candidate:amber-v1/); + const sources = results.locator(".research-record-sources"); + assert.doesNotMatch(await results.locator("li").innerText(), /run:old|candidate:amber-v1/, "Reading the observation does not expose protocol metadata by default"); + await sources.locator(":scope > summary").focus(); + await page.keyboard.press("Enter"); + assert.match(await sources.innerText(), /run:old|candidate:amber-v1/); + await page.keyboard.press("Enter"); assert.doesNotMatch(await results.locator("li").innerText(), /amber-v2|Later observation/); const status = results.getByRole("combobox", {name: "Recorded status"}); await status.selectOption("confirmed"); await readEmptyScope(page, results, "No matching records. Try another term or clear the filters."); assert.equal(await downloadButton.isDisabled(), true); await status.selectOption("refuted"); - await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); await results.locator(".research-history-records > li > details > summary").click(); const selected = await download(); assert.deepEqual(selected.filters, {query: "AMBER-V1 run:old", status: "refuted"}); @@ -98,6 +133,14 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await comparisonSummary.focus(); await page.keyboard.press("Enter"); await comparison.getByRole("combobox", {name: "Compare with"}).waitFor(); + const comparisonHelp = comparison.getByRole("button", {name: "Comparison scope and record identities", exact: true}); + await comparisonHelp.focus(); + const identities = page.getByRole("tooltip"); + assert.match(await identities.innerText(), /Compares original records/); + for (const record of all.records.filter(record => record.result_id === "retained-finding").slice(0, 2)) { + assert.match(await identities.innerText(), new RegExp(record.event_id)); + } + await page.keyboard.press("Escape"); assert.match(await comparison.innerText(), /Later observation/); assert.match(await comparison.innerText(), /质量下降/); assert.match(await comparison.innerText(), /candidate:amber-v1/); @@ -133,20 +176,20 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s }); await downloadButton.click(); await results.getByRole("alert").getByText(/Could not start the download/).waitFor(); - await results.scrollIntoViewIfNeeded(); + await results.evaluate(element => element.scrollIntoView({block: "start"})); await page.screenshot({path: resolve(outputDir, "research-record-export-download-error.png")}); assert.deepEqual((await download()).records, selected.records); assert.equal(await results.getByRole("alert").count(), 0); for (const width of [1440, 390]) { await page.setViewportSize({width, height: 900}); - await results.scrollIntoViewIfNeeded(); + await results.evaluate(element => element.scrollIntoView({block: "start"})); assert(await results.evaluate(element => element.scrollWidth <= element.clientWidth + 1), "Results fit the viewport"); await page.screenshot({path: resolve(outputDir, `research-record-search-${width}.png`), animations: "disabled"}); } // A failed refresh keeps the selected query and old evidence visibly stale. await page.route("**/api/chat/explore-results?*", route => route.fulfill({status: 503, json: {error: "Synthetic interruption"}})); await history.getByRole("button", {name: "Refresh history", exact: true}).click(); - await results.getByText(/Refresh failed; showing previously read records/).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).getByText(/Refresh failed/).waitFor(); assert.equal(await search.inputValue(), "AMBER-V1 run:old"); assert.equal(await results.locator("li").count(), 1); const stale = await download(); @@ -156,11 +199,11 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s assert.match(await comparison.innerText(), /candidate:amber-v2/); await comparisonSummary.click(); assert.deepEqual(stale.records, selected.records); - await results.scrollIntoViewIfNeeded(); + await results.evaluate(element => element.scrollIntoView({block: "start"})); await page.screenshot({path: resolve(outputDir, "research-record-export-stale.png")}); await page.unroute("**/api/chat/explore-results?*"); await history.getByRole("button", {name: "Refresh history", exact: true}).click(); - await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); assert.equal((await download()).coverage.refresh_failed, false); await results.getByRole("button", {name: "Clear filters"}).click(); await results.getByRole("button", {name: /Show more records/}).click(); @@ -177,6 +220,9 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await search.fill("amber-v1"); await readEmptyScope(page, results, "No matches in the loaded records; the remaining history is not verified."); assert.equal(await downloadButton.isDisabled(), true); + await results.getByRole("button", {name: "No matches in loaded records", exact: true}).click(); + await page.getByRole("tooltip").getByText("History is incomplete; remaining records have not been verified.", {exact: true}).waitFor(); + await page.keyboard.press("Escape"); await results.getByRole("button", {name: "Clear filters"}).click(); const partial = await download(); assert.equal(partial.coverage.history_complete, false); @@ -186,10 +232,10 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await search.fill("amber-v1"); await page.unroute("**/api/chat/explore-results?*"); await history.getByRole("button", {name: "Refresh history", exact: true}).click(); - await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); await page.goto(url.replace("synthetic-record-search", "synthetic-other-research")); await history.getByRole("button", {name: "Search research records", exact: true}).click(); - await results.getByText("1 matches in 1 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: "1 matches in 1 loaded records", exact: true}).waitFor(); assert.equal(await search.inputValue(), "", "Another Goal starts with its own search scope"); assert.doesNotMatch(await results.innerText(), /amber|Earlier negative/); const other = await download(); @@ -202,7 +248,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await page.goto(url); await history.getByRole("button", {name: "Search research records", exact: true}).click(); await search.fill("amber-v1"); - await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor(); + await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); // A pending refresh cannot export an intermediate mixture of pages. let release, observed; const hold = new Promise(resolve => {release = resolve;}); @@ -226,6 +272,13 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await results.getByText(/比较记录版本/).click(); await results.getByRole("combobox", {name: "比较对象"}).waitFor(); assert.match(await comparison.innerText(), /未测量/); + await results.evaluate(element => element.scrollIntoView({block: "start"})); + await page.screenshot({path: resolve(outputDir, "research-record-chinese-390.png"), animations: "disabled"}); + await comparison.locator(":scope > summary").click(); + await sources.locator(":scope > summary").click(); + assert.match(await sources.innerText(), /run:old|candidate:amber-v1/); + await results.evaluate(element => element.scrollIntoView({block: "start"})); + await page.screenshot({path: resolve(outputDir, "research-record-sources-390.png"), animations: "disabled"}); await results.getByRole("button", {name: "清除筛选"}).click(); const chineseDownload = results.getByRole("button", {name: "下载匹配记录(JSON)", exact: true}); const received = page.waitForEvent("download");