governance / governance (pull_request) Successful in 15s
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>
168 lines
10 KiB
TypeScript
168 lines
10 KiB
TypeScript
// 批注范围锚定(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('当前编制人不是你')
|
||
})
|
||
})
|