// 批注范围锚定(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('
目标
') expect(html).toContain('
旧目标
') 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[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('当前编制人不是你') }) })