From ae4a505d70a091c6d925ed4bf14a72d9cb0bda38 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 17:27:31 +0800 Subject: [PATCH] Stream reader results and navigate with cursors [skip ci] --- docs/paper-api.md | 24 ++++++++++++----- src/app/page.tsx | 46 ++++++++++++++++++++++---------- src/components/search-submit.tsx | 7 +++++ src/lib/papers.ts | 28 +++++++++---------- src/lib/sample-page.mjs | 19 +++++++++++++ tests/e2e/reader.spec.mjs | 11 +++++--- tests/fixtures/session-app.mjs | 10 ++++--- tests/paper-api.test.mjs | 6 ++--- tests/sample-page.test.mjs | 26 ++++++++++++++++++ tests/session/papers.spec.mjs | 18 +++++++++++++ 10 files changed, 149 insertions(+), 46 deletions(-) create mode 100644 src/components/search-submit.tsx create mode 100644 src/lib/sample-page.mjs create mode 100644 tests/sample-page.test.mjs diff --git a/docs/paper-api.md b/docs/paper-api.md index 6472894..c70fc86 100644 --- a/docs/paper-api.md +++ b/docs/paper-api.md @@ -1,6 +1,6 @@ # 论文只读 HTTP 接口 -状态:既有论文库已完成 PostgreSQL 迁移与独立验收。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的 codex/research-api 分支正在按实际 PostgreSQL schema 联调。本任务包括后端实现与联调,不把接口开发留给用户。 +状态:既有论文库已完成 PostgreSQL 迁移与独立验收。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的初版论文 API 已合并 main;本轮优化改用游标契约。本任务包括后端实现与联调,不把接口开发留给用户。 ## 地址与配置 @@ -14,13 +14,16 @@ PAPER_API_ORIGIN=https://api.foundry.entroia.com/projects/thesisly/research/ ## 列表 -GET {PAPER_API_ORIGIN}/papers?q=&searchIn=text&topic=&sort=recent&page=1&pageSize=8 +GET {PAPER_API_ORIGIN}/papers?q=&searchIn=text&topic=&sort=recent&cursor=&pageSize=8 - q:最多200个Unicode字符、20个词。searchIn=text时检索标题/摘要,searchIn=author时检索显示作者名。不会把用户输入作为任意SQL或FTS操作符。 - topic:精确arxiv:category值。 - sort:recent是稳定论文ID降序,即收录顺序,**不是发表日期排序**;title使用现有title_key索引。 -- page:1–1000;pageSize:1–50,前端当前为8。前端与此上限一致;结果超过8,000条时明确提示缩小搜索或领域范围,不生成不可访问的下一页链接。超出实际末页的页面地址会跳转至当前条件下的末页。 -- 返回{items:Paper[],total,topics,page,pageSize},同一只读事务内计数及取页;无效、重复、未知参数返回400。 +- pageSize:1–50,前端当前为8。每次取9个位置,显示8篇,以额外一条判断是否有下一页。 +- cursor:首屏省略;后续原样使用返回的 nextCursor / previousCursor。游标绑定关键词、范围、领域、排序和每页大小;修改筛选必须清除游标。空字符串表示没有该方向的下一页。 +- 返回 `{items:Paper[],nextCursor:string,previousCursor:string,topics:string[],pageSize:number}`。不再返回 total/page,也不接受旧 page 参数;前后端需协调切换。列表不执行全库或搜索精确计数,不依赖总页数或8,000条浏览上限。 +- recent 使用 ID 降序;title 使用存储的 title_key、ID 升序以区分同标题记录。每次请求是一个只读快照,跨页期间新增/删除/修改记录可能改变结果,不承诺整个浏览过程冻结语料库。 +- 无效、重复、未知参数返回400;游标不是账户凭据。手工修改或跨筛选复用游标需要清除筛选重新浏览。 ## 论文 @@ -32,7 +35,9 @@ source_records按paper_id关联;作者兼容[family,given,suffix]数组与字 已核对实际 PostgreSQL schema、编码标记和索引,并在服务器创建专用 SELECT 账号及受保护连接配置。已通过 SSH 回环隧道验证真实全库首页和详情(桌面/手机无页面异常和横向溢出),非常宽泛查询可能超出8秒预算;临时 Core 不是正式部署。不部署 SQLite sidecar,不复制整库,不从浏览器持有数据库连接串。 -最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。已补建作者和领域索引,并局部关闭 JIT、调整过滤取页顺序。实测作者搜索146条、cs.AR领域9457条、CartoonGAN搜索3条、多词全文检索8653条均返回200;非常宽泛的检索仍可能超出8秒预算,这是当前已知的性能限制。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。 +默认排序的纯领域查询直接沿现有领域/论文索引取页;全文查询先在游标附近512的 ID 区间找一页,有少量匹配但不足一页时扩展一次到4096,仍不足9个位置才丢弃局部候选、执行完整索引检索,保证不截断较旧或稀疏结果。标题排序仍使用完整候选检索,宽泛条件仍可能超时。 + +最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。已补建作者和领域索引,并局部关闭 JIT、调整过滤取页顺序。初版计数接口曾验证作者、领域和多词全文检索;非常宽泛的检索仍可能超出8秒预算,这是当前已知的性能限制。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。 ## 联调前置 @@ -46,4 +51,11 @@ source_records按paper_id关联;作者兼容[family,given,suffix]数组与字 ## 查询失败后的恢复 -列表将超时、繁忙、无效条件和服务不可用作为可恢复状态:保留关键词/搜索范围/领域,不显示未知总数和分页,不把错误显示成0条结果,也不退回样本。用户可直接修改关键词或重试。真实库已验证graph超时后修改为CartoonGAN恢复3条;隔离浏览器和匿名HTTP传输用例覆盖了错误分类、表单保留及异常响应处理。 +列表将超时、繁忙、无效条件和服务不可用作为可恢复状态:保留关键词/搜索范围/领域,不显示总数或可用分页,不把错误显示成0条结果,也不退回样本。用户可直接修改关键词或重试。真实库已验证graph超时后修改为CartoonGAN恢复3条;隔离浏览器和匿名HTTP传输用例覆盖了错误分类、表单保留及异常响应处理。 + + +## 页面请求与后续优化 + +首页标题、搜索和排序先渲染,论文结果与领域列表通过 Suspense 后续载入,并复用同一个列表请求。搜索/排序使用 Next Form 站内导航,保留根布局和登录状态;列表中的领域、文章和分页链接关闭自动预取,限制链接预取带来的后台请求;本轮未执行生产 build,未测量生产预取收益。查询仍有明确的等待、失败、空结果三种状态。 + +下一阶段应分别测量:登录回调到可操作页面的耗时、窗口焦点触发的会话检查频率、文章 metadata 与正文的重复读取。可采用会话请求合并/焦点检查冷却、请求内文章读取去重,以及公开列表的短时有界缓存;账户校验和权限不复用公开缓存。总量若有产品需要,独立后台统计并标注统计时间,不进入阅读和搜索的关键路径。优先实测这些站内改动,不调整 Traefik。 diff --git a/src/app/page.tsx b/src/app/page.tsx index bdaeca9..4757ac9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,42 @@ import Link from "next/link"; -import { redirect } from "next/navigation"; +import Form from "next/form"; +import { Suspense } from "react"; import { ArrowRight, ArrowUpRight, Search, X } from "lucide-react"; -import { listPapers, normalizeFilters, paperDate, MAX_PAPER_PAGE, type Filters } from "@/lib/papers"; +import { listPapers, normalizeFilters, paperDate, type Filters } from "@/lib/papers"; import { Button } from "@/components/ui/button"; -export default async function Home({ searchParams }: { searchParams: Promise> }) { - const f = normalizeFilters(await searchParams), data = await listPapers(f); - const pages = data.total === null ? 0 : Math.min(MAX_PAPER_PAGE, Math.ceil(data.total / data.pageSize)); +import { SearchSubmit } from "@/components/search-submit"; + +type Result = ReturnType; +function href(f: Filters, changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([,v]) => typeof v === "string" && !!v) as [string,string][]).toString(); } +function Pending() { return

正在读取论文索引…

; } +async function Topics({ result, f }: { result: Result; f: Filters }) { + const data = await result; + return ; +} +async function Results({ result, f }: { result: Result; f: Filters }) { + const data = await result; const issue = data.mode === "error" ? { timeout: ["这次检索耗时较长", "试试更具体的关键词,或清除筛选后重新浏览。"], busy: ["论文索引正在处理较多请求", "请稍候重试。你的搜索条件已保留。"], invalid_query: ["请调整搜索条件", "请缩短关键词或清除筛选后重试。"], unavailable: ["论文暂时无法读取", "请稍后重试。你的搜索条件已保留。"], }[data.reason] : null; - function href(changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([, v]) => typeof v === "string" && !!v) as [string, string][]).toString(); } - if (!issue && data.page > Math.max(1, pages)) redirect(href({ page: String(Math.max(1, pages)) })); - return
THE OPEN RESEARCH READER 01 — INDEX

好研究,
值得被读到。

从一个问题,到一篇论文。
发现研究、阅读摘要,回到每一项工作的原始出处。

浏览论文
- {data.mode === "sample" &&

真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。

数据说明
} -

{f.topic || "论文索引"}{data.total !== null && {data.total.toLocaleString("zh-CN")}}

-
- {(f.q || f.topic) &&
{f.q ? `搜索 “${f.q}”` : f.topic}清除筛选
} - {issue ?

{issue[0]}

{issue[1]}

: data.items.length ?
    {data.items.map((p, i) =>
  1. {String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}
    {f.topic || p.topics[0] || "Research"}{paperDate(p.publishedAt)}

    {p.title}

    {p.abstract || "来源未提供摘要。"}

    {p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}阅读简介
  2. )}
:

没有找到符合条件的论文

{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}

} - {!issue && }{data.total !== null && data.total > MAX_PAPER_PAGE * data.pageSize &&

当前最多浏览前 {(MAX_PAPER_PAGE * data.pageSize).toLocaleString("zh-CN")} 条结果。搜索或筛选研究领域,可进一步缩小范围。

}
; + return <> + {issue ?

{issue[0]}

{issue[1]}

: data.items.length ?
    {data.items.map((p, i) =>
  1. {String(i + 1).padStart(2, "0")}
    {f.topic || p.topics[0] || "Research"}{paperDate(p.publishedAt)}

    {p.title}

    {p.abstract || "来源未提供摘要。"}

    {p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}阅读简介
  2. )}
:

没有找到符合条件的论文

{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}

} + + {data.mode !== "error" && } + ; +} +export default async function Home({ searchParams }: { searchParams: Promise> }) { + const f = normalizeFilters(await searchParams); + const result = listPapers(f); + return
THE OPEN RESEARCH READER 01 — INDEX

好研究,
值得被读到。

从一个问题,到一篇论文。
发现研究、阅读摘要,回到每一项工作的原始出处。

浏览论文
+ + {!process.env.PAPER_API_ORIGIN &&

真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。

数据说明
} +

研究领域

正在读取领域…

}>

{f.topic || "论文索引"}

+
+ {(f.q || f.topic) &&
{f.q ? `搜索 “${f.q}”` : f.topic}清除筛选
} + + }> +
; } diff --git a/src/components/search-submit.tsx b/src/components/search-submit.tsx new file mode 100644 index 0000000..9c43909 --- /dev/null +++ b/src/components/search-submit.tsx @@ -0,0 +1,7 @@ +"use client"; +import { useFormStatus } from "react-dom"; +import { ArrowRight, LoaderCircle } from "lucide-react"; +export function SearchSubmit({ label, icon = false }: { label: string; icon?: boolean }) { + const { pending } = useFormStatus(); + return ; +} diff --git a/src/lib/papers.ts b/src/lib/papers.ts index ef93462..c8cb2db 100644 --- a/src/lib/papers.ts +++ b/src/lib/papers.ts @@ -1,16 +1,16 @@ +import { samplePage } from "./sample-page.mjs"; import snapshot from "@/data/papers.json"; import { createPaperTransport } from "./paper-api.mjs"; export type Paper = { id: string; title: string; abstract: string | null; authors: string[]; publishedAt: string | null; topics: string[]; sources: { label: string; url: string; externalId: string }[]; doi?: string | null; journal?: string | null; comments?: string | null; publications?: { venue: string; year: number; track: string; status: string; evidenceUrl?: string }[] }; -export type PaperPage = { items: Paper[]; total: number; page: number; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string }; -export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; total: null; page: number; pageSize: number; topics: string[] }; -export type Filters = { q?: string; topic?: string; sort?: string; page?: string; searchIn?: string }; +export type PaperPage = { items: Paper[]; nextCursor: string; previousCursor: string; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string }; +export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; pageSize: number; topics: string[] }; +export type Filters = { q?: string; topic?: string; sort?: string; cursor?: string; searchIn?: string }; export function normalizeFilters(input: Record): Filters { const value = (key: string) => typeof input[key] === "string" ? (input[key] as string).slice(0, 200) : undefined; - return { q: value("q"), topic: value("topic"), sort: value("sort"), page: value("page"), searchIn: value("searchIn") }; + return { q: value("q"), topic: value("topic"), sort: value("sort"), cursor: typeof input.cursor === "string" ? input.cursor : undefined, searchIn: value("searchIn") }; } const size = 8; -export const MAX_PAPER_PAGE = 1000; export function safeExternalUrl(input: string | null | undefined) { if (!input) return null; try { const u = new URL(input); return u.protocol === "https:" || u.protocol === "http:" ? u.href : null; } catch { return null; } @@ -24,22 +24,20 @@ async function remote(path: string) { return createPaperTransport(process.env.PAPER_API_ORIGIN || "")(path); } export async function listPapers(filters: Filters): Promise { - const page = Math.max(1, Math.min(MAX_PAPER_PAGE, Number.parseInt(filters.page || "1", 10) || 1)); const q = (filters.q || "").trim().slice(0, 200), topic = filters.topic || "", sort = filters.sort === "title" ? "title" : "recent"; if (process.env.PAPER_API_ORIGIN) { - const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", page: String(page), pageSize: String(size) }); - const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], total: null, page, pageSize: size, topics: topic ? [topic] : [] }); + const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", cursor: filters.cursor || "", pageSize: String(size) }); + const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], pageSize: size, topics: topic ? [topic] : [] }); + if ((filters.cursor || "").length > 32768) return failure(400); const response = await remote("papers?" + params); if (!response.ok) return failure(response.status ?? 503); const data = response.data; - if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502); - return { ...data, page, pageSize: size, mode: "live" }; + if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || typeof data.nextCursor !== "string" || typeof data.previousCursor !== "string" || data.items.length > size || data.pageSize !== size || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502); + return { ...data, pageSize: size, mode: "live" }; } - const all: Paper[] = snapshot.papers; - const topics = Array.from(new Set(all.flatMap(p => p.topics))).sort(); - const items = all.filter(p => (!topic || p.topics.includes(topic)) && (!q || (filters.searchIn === "author" ? p.authors : [p.title, p.abstract]).join(" ").toLocaleLowerCase().includes(q.toLocaleLowerCase()))); - items.sort((a, b) => sort === "title" ? a.title.localeCompare(b.title) : Number(b.id) - Number(a.id)); - return { items: items.slice((page - 1) * size, page * size), total: items.length, page, pageSize: size, topics, mode: "sample", capturedAt: snapshot.capturedAt }; + try { + return { ...samplePage(snapshot.papers, { q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", cursor: filters.cursor || "", pageSize: size }), mode: "sample", capturedAt: snapshot.capturedAt }; + } catch { return { mode: "error", reason: "invalid_query", items: [], pageSize: size, topics: topic ? [topic] : [] }; } } export async function getPaper(id: string): Promise { if (!/^[1-9][0-9]{0,18}$/.test(id) || BigInt(id) > 9223372036854775807n) return null; diff --git a/src/lib/sample-page.mjs b/src/lib/sample-page.mjs new file mode 100644 index 0000000..3d54d28 --- /dev/null +++ b/src/lib/sample-page.mjs @@ -0,0 +1,19 @@ +// The explicit 20-record preview uses the same navigation shape as the API. +// Its cursors are intentionally local to sample mode, never corpus positions. +export function samplePage(papers, filters) { + const { q = "", topic = "", sort = "recent", searchIn = "text", cursor = "", pageSize = 8 } = filters; + const key = JSON.stringify([q, topic, sort, searchIn, pageSize]); + const all = papers.filter(p => (!topic || p.topics.includes(topic)) && (!q || (searchIn === "author" ? p.authors : [p.title, p.abstract]).join(" ").toLocaleLowerCase().includes(q.toLocaleLowerCase()))); + all.sort((a,b) => sort === "title" ? a.title.localeCompare(b.title) || (BigInt(a.id) < BigInt(b.id) ? -1 : 1) : BigInt(a.id) > BigInt(b.id) ? -1 : 1); + let start = 0; + if (cursor) { + if (cursor.length > 32768 || !/^[A-Za-z0-9_-]+$/.test(cursor)) throw Error("invalid_cursor"); + const position = JSON.parse(Buffer.from(cursor, "base64url").toString()); + const index = all.findIndex(p => p.id === position.id); + if (position.sample !== 1 || position.key !== key || typeof position.before !== "boolean" || index < 0) throw Error("invalid_cursor"); + start = position.before ? Math.max(0, index-pageSize) : index+1; + } + const items = all.slice(start,start+pageSize); + const encode = (p,before) => Buffer.from(JSON.stringify({sample:1,key,id:p.id,before})).toString("base64url"); + return {items, pageSize, topics:[...new Set(papers.flatMap(p=>p.topics))].sort(), previousCursor:start>0 && items.length ? encode(items[0],true) : "", nextCursor:start+items.length { if (typeof data.logoutFailure === "boolean") logoutFailure = data.logoutFailure; if ([0, 400, 429, 503, 504].includes(data.paperStatus)) paperStatus = data.paperStatus; if (typeof data.paperMalformed === "boolean") paperMalformed = data.paperMalformed; + if (typeof data.paperDelay === "number") paperDelay = Math.min(5000, Math.max(0,data.paperDelay)); return reply(res, 200, {}); } if (url.pathname.startsWith("/projects/thesisly/research/papers/")) { @@ -54,11 +55,12 @@ const server = createServer(async (req, res) => { return reply(res, paper ? 200 : 404, paper || { error: "not_found" }); } if (url.pathname === "/projects/thesisly/research/papers") { + if (paperDelay) await new Promise(resolve => setTimeout(resolve, paperDelay)); if (paperStatus) return reply(res, paperStatus, { error: "synthetic research failure" }); if (paperMalformed) return reply(res, 200, { unexpected: true }); - const page = Number(url.searchParams.get("page") || 1), pageSize = Number(url.searchParams.get("pageSize") || 8); + const pageSize = Number(url.searchParams.get("pageSize") || 8); // Fixture transport data, not an implementation of Core search semantics. - return reply(res, 200, { items: snapshot.papers.slice((page - 1) * pageSize, page * pageSize), total: snapshot.papers.length, page, pageSize, topics: [...new Set(snapshot.papers.flatMap(p => p.topics))] }); + return reply(res, 200, { items: snapshot.papers.slice(0, pageSize), nextCursor: "", previousCursor: "", pageSize, topics: [...new Set(snapshot.papers.flatMap(p => p.topics))] }); } const match = /^\/projects\/thesisly\/auth\/([a-z-]+)$/.exec(url.pathname); if (!match) return reply(res, 404, { error: "not_found" }); diff --git a/tests/paper-api.test.mjs b/tests/paper-api.test.mjs index 099e6f8..cfc9e6b 100644 --- a/tests/paper-api.test.mjs +++ b/tests/paper-api.test.mjs @@ -5,14 +5,14 @@ const origin = "https://core.example.test/projects/thesisly/research/"; test("paper transport uses the anonymous no-store API without forwarding site credentials", async () => { const transport = createPaperTransport(origin, async (url, options) => { - assert.equal(url.href, origin + "papers?q=graph&page=2"); + assert.equal(url.href, origin + "papers?q=graph&cursor=opaque"); assert.equal(options.cache, "no-store"); assert.equal(options.redirect, "error"); assert.equal(options.headers, undefined); assert.ok(options.signal instanceof AbortSignal); - return Response.json({ items: [], total: 0, topics: [], page: 2, pageSize: 8 }); + return Response.json({ items: [], nextCursor: "", previousCursor: "", topics: [], pageSize: 8 }); }); - assert.deepEqual(await transport("papers?q=graph&page=2"), { ok: true, data: { items: [], total: 0, topics: [], page: 2, pageSize: 8 } }); + assert.deepEqual(await transport("papers?q=graph&cursor=opaque"), { ok: true, data: { items: [], nextCursor: "", previousCursor: "", topics: [], pageSize: 8 } }); }); test("HTTP errors stay distinguishable and never become empty search results", async () => { diff --git a/tests/sample-page.test.mjs b/tests/sample-page.test.mjs new file mode 100644 index 0000000..438b20d --- /dev/null +++ b/tests/sample-page.test.mjs @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { samplePage } from '../src/lib/sample-page.mjs'; +const papers=JSON.parse(readFileSync(new URL('../src/data/papers.json',import.meta.url))).papers; +test('sample cursor traversal reaches every record and reverses without duplication',()=>{ + for(const sort of ['recent','title']) { + let cursor='',previous=[];const seen=[]; + do { + const page=samplePage(papers,{sort,cursor,pageSize:8}); + if(previous.length) assert.deepEqual(samplePage(papers,{sort,cursor:page.previousCursor,pageSize:8}).items.map(p=>p.id),previous); + previous=page.items.map(p=>p.id);seen.push(...previous);cursor=page.nextCursor; + }while(cursor); + assert.equal(seen.length,20);assert.equal(new Set(seen).size,20); + } +}); +test('sample cursors reject cross-filter reuse and malformed input',()=>{ + const page=samplePage(papers,{}); + for(const filters of [{q:'graph'},{topic:'cs.LG'},{sort:'title'},{searchIn:'author'},{pageSize:9}]) assert.throws(()=>samplePage(papers,{...filters,cursor:page.nextCursor})); + for(const cursor of ['!','invalid', 'a'.repeat(32769)]) assert.throws(()=>samplePage(papers,{cursor})); +}); +test('sample title ties and IDs beyond Number precision have deterministic order',()=>{ + const rows=['9007199254740993','9007199254740992','1'].map(id=>({...papers[0],id,title:'Same title'})); + assert.deepEqual(samplePage(rows,{sort:'title'}).items.map(p=>p.id),['1','9007199254740992','9007199254740993']); + assert.deepEqual(samplePage(rows,{}).items.map(p=>p.id),['9007199254740993','9007199254740992','1']); +}); diff --git a/tests/session/papers.spec.mjs b/tests/session/papers.spec.mjs index 86adad9..526c7b6 100644 --- a/tests/session/papers.spec.mjs +++ b/tests/session/papers.spec.mjs @@ -42,3 +42,21 @@ test("malformed research responses do not become empty results or sample mode", await expect(page.getByRole("textbox", { name: "搜索论文", exact: true })).toHaveValue("keep-this-query"); await expect(page.locator(".paper-row, .sample-note, .index-heading h2 span, .pagination")).toHaveCount(0); }); + + +test("slow results stream after usable search controls and searches retain the document", async ({ page, request }) => { + await control(request, { paperDelay: 3000 }); + await page.goto("/", { waitUntil: "commit" }); + await expect(page.getByRole("textbox", {name:"搜索论文",exact:true})).toBeVisible({timeout:2000}); + await expect(page.getByRole("status")).toContainText("正在读取论文索引"); + await expect(page.locator(".paper-row")).toHaveCount(8); + await page.evaluate(() => { window.__readerDocument = "preserved"; }); + let documents = 0, sessions = 0; + page.on("request", req => { if(req.isNavigationRequest() && req.frame() === page.mainFrame()) documents++; if(new URL(req.url()).pathname.endsWith("/auth/me")) sessions++; }); + await page.getByRole("textbox", {name:"搜索论文",exact:true}).fill("graph"); + await page.getByRole("button", {name:"搜索论文",exact:true}).click(); + await expect(page).toHaveURL(/q=graph/); + await expect(page.locator(".paper-row")).toHaveCount(8); + expect(await page.evaluate(() => window.__readerDocument)).toBe("preserved"); + expect(documents).toBe(0); expect(sessions).toBe(0); +});