Files
Agent-System-Frontend/test/review-annotation-anchor.test.ts
weichenandCursor 50d12ce974
governance / governance (pull_request) Successful in 15s
Anchor review annotations on the selected node text range
Why: RDM now anchors annotations on nodeId + textStart + textEnd + textHash
(spec 06 §2) and acceptance 10 §5 requires accurate comment ranges. The
workspace only sent whole-node annotations and ignored quote/needsReanchor.

- Selecting text inside one node's title in the reader view records a
  code-point range (Array.from indices; surrogate pairs and ZWJ emoji are
  counted as runes, half-pair endpoints widen) and sends textStart/textEnd
  plus quote, so RDM rejects rather than misplaces a drifted range.
- No selection keeps whole-node behavior; replies never carry range params;
  textHash/range are never sent; a range is dropped if it belongs to another
  node.
- Selections spanning several nodes or containing no node text record no
  range and show a notice that the whole node will be annotated; the composer
  always shows the scope with a "whole node" escape.
- Annotation items show the server quote and a visible needs-reanchor state;
  review_anchor_invalid: errors map to readable Chinese text.
- Reader titles become role=button spans because browsers do not allow text
  selection inside <button>.

Evidence: npm test 40 files / 486 tests pass (30 new in
test/review-annotation-anchor.test.ts); npm run build exit 0;
node scripts/check-knip.mjs "50 <= 51"; design-guard 7/7; playwright
--list compiles 13 tests; headless Chromium check of the selection reader
(single, multi-node, triple-click clamp, emoji offsets).

Impact: document review UI only; base-gate decision source anchoring is
unchanged. Offsets are computed on the displayed node title (no frozen-text
read endpoint exists), guarded by quote equality on the server.

Refs: unified renderer plan Task 13; phase1 F2; spec 06 §2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 06:27:22 +00:00

168 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 批注范围锚定(06 §2:nodeId + textStart + textEnd + textHash;10 §5「批注范围准确」)。
// RDM 契约:偏移是冻结节点文本上的 Unicode 码点(Go rune = JS Array.from 下标)半开区间;
// quote 与范围同发时须等于范围原文;回复不带范围;textHash/range 永不由前端发送。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
annotatePayload, codePointRange, describeRenderFailure, normalizeReviewView, partitionAnnotations, selectionAnchor,
} from '../src/documentRenderPresentation'
import { AnnotationGroups, AnnotationScope } from '../src/components/DocumentRenderWorkspace'
describe('码点偏移(UTF-16 选区 → RDM rune 偏移)', () => {
it('ASCII:半开区间,参数顺序无关,越界裁剪', () => {
expect(codePointRange('Hello world', 6, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 11, 6)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 6, 99)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
})
it('首尾空白不计入,纯空白或空选区不记录范围', () => {
expect(codePointRange('Hello world', 5, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
expect(codePointRange('Hello world', 5, 6)).toBeUndefined()
expect(codePointRange('Hello world', 3, 3)).toBeUndefined()
})
it('CJK:与契约示例一致("审阅正文 Review" 中 Review 为 5..11)', () => {
expect(codePointRange('审阅正文 Review', 5, 11)).toEqual({ textStart: 5, textEnd: 11, quote: 'Review' })
expect(codePointRange('项目内容、目标及意义', 5, 7)).toEqual({ textStart: 5, textEnd: 7, quote: '目标' })
})
it('emoji 代理对:UTF-16 下标换算为码点,后续字符不错位', () => {
const text = '审阅👍正文 Review'
expect(text.indexOf('Review')).toBe(7)
expect(codePointRange(text, 7, 13)).toEqual({ textStart: 6, textEnd: 12, quote: 'Review' })
expect(Array.from(text).slice(6, 12).join('')).toBe('Review')
expect(codePointRange(text, 2, 4)).toEqual({ textStart: 2, textEnd: 3, quote: '👍' })
})
it('端点落在代理对中间时外扩为完整字符,绝不发出半个字符', () => {
const text = '审阅👍正文'
expect(codePointRange(text, 3, 5)).toEqual({ textStart: 2, textEnd: 4, quote: '👍正' })
expect(codePointRange(text, 0, 3)).toEqual({ textStart: 0, textEnd: 3, quote: '审阅👍' })
})
it('ZWJ 组合 emoji 按码点(rune)计数而非字形', () => {
const family = '👨\u200d👩\u200d👧'
const text = `a${family}b`
expect(codePointRange(text, 1, 1 + family.length)).toEqual({ textStart: 1, textEnd: 6, quote: family })
})
})
describe('阅读视图选区判定', () => {
const hit = (nodeId: string, text: string, start: number, end: number) => ({ nodeId, text, start, end })
it('无选区或折叠:不记录', () => {
expect(selectionAnchor(null)).toEqual({ kind: 'none' })
})
it('落在单个节点正文内:记录该节点的码点范围', () => {
expect(selectionAnchor([hit('dn-head', '项目内容、目标及意义', 5, 7)]))
.toEqual({ kind: 'range', anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
})
it('只擦到下一节点起点(零长度交集)仍算单节点', () => {
expect(selectionAnchor([hit('a', 'Alpha', 0, 5), hit('b', 'Beta', 0, 0)]))
.toEqual({ kind: 'range', anchor: { nodeId: 'a', textStart: 0, textEnd: 5, quote: 'Alpha' } })
})
it('跨越多个节点:说明原因,不记录范围(提交批注整个节点)', () => {
const r = selectionAnchor([hit('a', 'Alpha', 2, 5), hit('b', 'Beta', 0, 2)])
expect(r.kind).toBe('multi-node')
expect(r.kind === 'multi-node' && r.notice).toContain('跨越多个节点')
expect(r.kind === 'multi-node' && r.notice).toContain('批注整个节点')
})
it('选区不含任何节点正文(或只有空白):说明原因,不记录范围', () => {
expect(selectionAnchor([]).kind).toBe('outside-text')
expect(selectionAnchor([hit('a', 'A b', 1, 2)]).kind).toBe('outside-text')
})
})
describe('rdm.render.review.annotate 载荷', () => {
const base = { projectId: 'p', submissionId: 'sub', treeNodeId: 'dn-head', text: ' 批 ', operationId: 'op' }
const range = { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' }
it('有选区:发送 textStart/textEnd/quote,绝不发送 textHash 或 range', () => {
const p = annotatePayload({ ...base, range })
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', textStart: 5, textEnd: 7, quote: '目标' })
expect(p).not.toHaveProperty('textHash')
expect(p).not.toHaveProperty('range')
})
it('无选区:整节点批注,不带任何范围参数', () => {
for (const p of [annotatePayload(base), annotatePayload({ ...base, range: null })]) {
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op' })
}
})
it('回复:即使持有选区也不带范围(继承父批注锚点)', () => {
const p = annotatePayload({ ...base, replyTo: 'a1', range })
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', replyTo: 'a1' })
for (const k of ['textStart', 'textEnd', 'quote', 'textHash', 'range']) expect(p).not.toHaveProperty(k)
})
it('范围不属于目标节点时不发送(绝不把 A 节点的偏移挂到 B 节点)', () => {
expect(annotatePayload({ ...base, range: { ...range, nodeId: 'dn-other' } })).not.toHaveProperty('textStart')
})
})
describe('审阅视图:摘录与需重新锚定', () => {
const person = { id: 'u1', displayName: '审核人' }
const view = normalizeReviewView({
annotations: [
{ id: 'a1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '精确锚定', quote: '目标', needsReanchor: false,
range: { start: 5, end: 7, textHash: 'h' }, replies: [{ id: 'r1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '回复', quote: '目标' }] },
{ id: 'a2', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '漂移了', quote: '旧目标', needsReanchor: true, replies: [] },
{ id: 'a3', nodeId: 'pp.sig', treeNodeId: 'dn-head', author: person, createdAt: '', text: '节点级', replies: [] },
],
})
it('归一化保留 quote 与 needsReanchor;缺省不补造', () => {
expect(view.annotations[0]).toMatchObject({ quote: '目标' })
expect(view.annotations[0]).not.toHaveProperty('needsReanchor')
expect(view.annotations[1]).toMatchObject({ quote: '旧目标', needsReanchor: true })
expect(view.annotations[2]).not.toHaveProperty('quote')
})
it('批注项显示摘录;需重新锚定时有可见状态与说明', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['dn-head']))
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '项目内容、目标及意义', onSelectNode: () => {} }))
expect(html).toContain('<blockquote class="drw-anno-quote" title="目标">目标</blockquote>')
expect(html).toContain('<blockquote class="drw-anno-quote" title="旧目标">旧目标</blockquote>')
expect(html.match(/需重新锚定/g)).toHaveLength(1)
expect(html).toContain('drw-anno needs-reanchor')
expect(html).toContain('上方为旧摘录')
expect(html.match(/drw-anno-quote/g)).toHaveLength(2)
})
})
describe('撰写区的批注范围', () => {
const render = (props: Partial<Parameters<typeof AnnotationScope>[0]>) =>
renderToStaticMarkup(createElement(AnnotationScope, { anchor: null, notice: '', replying: false, onClear: () => {}, ...props }))
it('有选区:显示摘录、码点位置与改为整个节点', () => {
const html = render({ anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
expect(html).toContain('「目标」')
expect(html).toContain('第 6–7 字')
expect(html).toContain('改为整个节点')
})
it('无选区:明示整个节点', () => {
expect(render({})).toContain('整个节点(在阅读视图中选中该节点的文字')
})
it('选区无效:显示说明', () => {
expect(render({ notice: '所选文字跨越多个节点' })).toContain('role="status">所选文字跨越多个节点')
})
it('回复:沿用原批注范围,不显示选区', () => {
const html = render({ replying: true, anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
expect(html).toContain('回复沿用原批注的范围')
expect(html).not.toContain('目标')
})
})
describe('review_anchor_invalid: 转为中文可读文案(原始回执保留)', () => {
const cases: [string, string][] = [
['review_anchor_invalid: node pp.sig (image) has no frozen text a range can anchor; address a paragraph, heading, list item or table cell', '没有可按范围批注的正文'],
['review_anchor_invalid: quote does not match the frozen text of node pp.goal at 5..7', '与提交审阅时冻结的正文不一致'],
['review_anchor_invalid: quote occurs more than once in node pp.goal; supply textStart and textEnd', '出现多次'],
['review_anchor_invalid: quote is not in the frozen text of node pp.goal', '不在该节点冻结的正文中'],
['review_anchor_invalid: textStart/textEnd 5..99 out of range for node pp.goal (10 characters)', '超出该节点冻结正文的长度'],
['review_anchor_invalid: reply must address the parent\'s node pp.goal; replies inherit the parent\'s anchor', '回复只能针对原批注所在的节点'],
['textStart and textEnd must be supplied together', '起止位置须成对提供'],
['review_anchor_invalid: something new', '批注范围无效'],
]
it.each(cases)('%s', (msg, expected) => {
for (const raw of [msg, `400: ${JSON.stringify({ error: msg })}`]) {
const f = describeRenderFailure(new Error(raw))
expect(f.message).toContain(expected)
expect(f.message).not.toContain('review_anchor_invalid')
expect(f.raw).toBe(raw)
}
})
it('非范围错误不受影响', () => {
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
})
})