| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- <?php
- use App\Services\AnnotationLayout;
- /**
- * 阅读页批注排版:glossary 替换、issue 高亮、note 插入,以及它们交叠时的处理。
- * 偏移一律是字符偏移(mb),区间半开 [start, end)。
- */
- function layoutGlossary(string $id, int $start, int $end, string $title): array
- {
- return compact('id', 'start', 'end', 'title');
- }
- function layoutIssue(string $id, ?int $start, ?int $end, ?string $severity = 'warning'): array
- {
- return compact('id', 'start', 'end', 'severity');
- }
- function layoutNote(string $id, int $at, string $html, array $order = [0]): array
- {
- return compact('id', 'at', 'html', 'order');
- }
- /** 把 issue 的开标签压成 <I:ids> 便于断言 */
- function layoutTags(string $html): string
- {
- $html = preg_replace('/<span class="anno-issue anno-issue-\w+" data-discussion-ids="([^"]*)" data-severity="\w*">/', '<I:$1>', $html);
- return preg_replace('/<span class="anno-issue-mark anno-issue-(\w+)" data-discussion-ids="([^"]*)".*?<\/i><\/span>/', '<!$1:$2>', $html);
- }
- // 原文:比库入村托钵(0 比 1 库 2 入 3 村 4 托 5 钵)
- const LAYOUT_TEXT = '比库入村托钵';
- /* ------------------------------------------------------------------ locate */
- it('locates by position when the quote agrees', function () {
- expect(AnnotationLayout::locate(LAYOUT_TEXT, 2, 4, '入村'))->toBe([2, 4]);
- });
- it('relocates by quote when the position is stale, preferring prefix/suffix, then the nearest', function () {
- $text = '村里有村,村外有村';
- // 位置失效(正文改过),按摘录找;有两处「有村」,后缀「外」只吻合第一处之后……这里用前缀「,村外」选第二处
- expect(AnnotationLayout::locate($text, 0, 1, '有村', '村外', ''))->toBe([7, 9])
- ->and(AnnotationLayout::locate($text, 6, 7, '有村'))->toBe([7, 9]) // 无前后缀:取离原位置最近的
- ->and(AnnotationLayout::locate($text, null, null, '有村'))->toBe([2, 4]) // 无位置:取第一处
- ->and(AnnotationLayout::locate($text, 0, 2, '没有'))->toBeNull()
- ->and(AnnotationLayout::locate($text, 3, 99, null))->toBeNull() // 越界且无摘录
- ->and(AnnotationLayout::locate($text, 3, 4, null))->toBe([3, 4]);
- });
- /* ------------------------------------------------------------------ glossary */
- it('replaces a glossary span with [[title#text]]', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g1', 0, 2, 'bhikkhu')], [], []);
- expect($out['content'])->toBe('[[bhikkhu#比库]]入村托钵')
- ->and($out['glossaries'])->toBe(['g1']);
- });
- it('keeps the outer glossary when one contains another, and the earlier one when they cross', function () {
- $contain = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('in', 1, 2, 'a'), layoutGlossary('out', 0, 4, 'b')], [], []);
- $cross = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('late', 3, 6, 'a'), layoutGlossary('early', 2, 4, 'b')], [], []);
- $touch = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('x', 0, 2, 'a'), layoutGlossary('y', 2, 4, 'b')], [], []);
- expect($contain['content'])->toBe('[[b#比库入村]]托钵')
- ->and($cross['content'])->toBe('比库[[b#入村]]托钵')
- ->and($touch['content'])->toBe('[[a#比库]][[b#入村]]托钵'); // 首尾相接不算交叠
- });
- it('drops a glossary that would cut existing markup or contains reserved characters', function () {
- $text = '缘起于[[sāvatthī]]。'; // 受保护区间 [3, 17)
- $out = AnnotationLayout::apply($text, [
- layoutGlossary('cut', 1, 5, 'x'), // 切进已有术语
- layoutGlossary('ok', 0, 2, 'y'),
- ], [], []);
- $bad = AnnotationLayout::apply('a|b c', [layoutGlossary('pipe', 0, 3, 't'), layoutGlossary('hash', 4, 5, 'a#b')], [], []);
- expect($out['content'])->toBe('[[y#缘起]]于[[sāvatthī]]。')
- ->and($bad['content'])->toBe('a|b c')
- ->and($bad['glossaries'])->toBe([]);
- });
- /* ------------------------------------------------------------------ issue */
- it('wraps an issue span with its id and severity class', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('i1', 2, 4, 'error')], []);
- expect($out['content'])->toBe('比库<span class="anno-issue anno-issue-error" data-discussion-ids="i1" data-severity="error">入村</span>托钵');
- });
- it('merges overlapping and nested issues, taking the union of ids and the heaviest severity', function () {
- $overlap = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 3, 'suggestion'), layoutIssue('b', 2, 5, 'error')], []);
- $nested = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('outer', 0, 6), layoutIssue('inner', 2, 3, 'error')], []);
- $chain = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('c', 4, 6), layoutIssue('b', 1, 5)], []);
- $touch = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('b', 2, 4)], []);
- expect(layoutTags($overlap['content']))->toBe('<I:a,b>比库入村托</span>钵')
- ->and($overlap['issues'][0]['severity'])->toBe('error')
- ->and(layoutTags($nested['content']))->toBe('<I:outer,inner>比库入村托钵</span>')
- ->and(layoutTags($chain['content']))->toBe('<I:a,b,c>比库入村托钵</span>') // 第三条把两条不相交的连起来
- ->and(layoutTags($touch['content']))->toBe('<I:a>比库</span><I:b>入村</span>托钵');
- });
- it('extends an issue to fully contain a glossary it partly overlaps', function () {
- // glossary 比库 [0,2);issue 库入 [1,3) 交叉 → 扩成 [0,3)
- $cross = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 'bhikkhu')], [layoutIssue('i', 1, 3)], []);
- // issue 在 glossary 内部 → 扩成与 glossary 相同
- $inside = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 4, 't')], [layoutIssue('i', 1, 2)], []);
- // issue 已包住 glossary → 不动
- $contains = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 2, 4, 't')], [layoutIssue('i', 1, 5)], []);
- expect(layoutTags($cross['content']))->toBe('<I:i>[[bhikkhu#比库]]入</span>村托钵')
- ->and(layoutTags($inside['content']))->toBe('<I:i>[[t#比库入村]]</span>托钵')
- ->and(layoutTags($contains['content']))->toBe('比<I:i>库[[t#入村]]托</span>钵');
- });
- it('merges issues that only meet after being extended over a glossary', function () {
- // glossary [2,4) 入村;issue a [0,3) 扩到 [0,4),issue b [3,6) 扩到 [2,6),两者因此交叠 → 合并
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 2, 4, 't')], [layoutIssue('a', 0, 3), layoutIssue('b', 3, 6)], []);
- expect(layoutTags($out['content']))->toBe('<I:a,b>比库[[t#入村]]托钵</span>');
- });
- it('extends an issue over existing markup instead of cutting it', function () {
- $text = '缘起于[[sāvatthī]]。';
- $out = AnnotationLayout::apply($text, [], [layoutIssue('i', 2, 6)], []);
- expect(layoutTags($out['content']))->toBe('缘起<I:i>于[[sāvatthī]]</span>。');
- });
- it('puts issues without a usable position into one end mark coloured by the heaviest severity', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [
- layoutIssue('a', null, null, 'suggestion'),
- layoutIssue('b', 3, 99, 'error'), // 越界当作没有位置
- layoutIssue('c', 0, 2, 'warning'),
- ], []);
- expect(layoutTags($out['content']))->toBe('<I:c>比库</span>入村托钵<!error:a,b>');
- });
- it('emits no issue markup when issues are turned off, but still replaces glossaries', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 'bhikkhu')], [layoutIssue('i', 0, 4), layoutIssue('j', null, null)], [], false);
- expect($out['content'])->toBe('[[bhikkhu#比库]]入村托钵');
- });
- /* ------------------------------------------------------------------ note */
- it('moves a note out of a glossary or existing markup, but leaves it inside an issue', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 't')], [layoutIssue('i', 2, 6)], [
- layoutNote('n1', 1, '{N1}'), // glossary 内部 → 挪到 2
- layoutNote('n2', 4, '{N2}'), // issue 内部 → 原地
- ]);
- $markup = AnnotationLayout::apply('缘起于[[sāvatthī]]。', [], [], [layoutNote('n', 5, '{N}')]);
- expect(layoutTags($out['content']))->toBe('[[t#比库]]{N1}<I:i>入村{N2}托钵</span>')
- ->and($markup['content'])->toBe('缘起于[[sāvatthī]]{N}。');
- });
- it('orders closing issue, notes, then opening issue at the same position', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('b', 2, 4)], [
- layoutNote('late', 2, '{L}', [1]),
- layoutNote('early', 2, '{E}', [0]),
- ]);
- expect(layoutTags($out['content']))->toBe('<I:a>比库</span>{E}{L}<I:b>入村</span>托钵')
- ->and($out['notes'])->toBe(['early', 'late']);
- });
- it('clamps note positions into the sentence and appends end notes before the issue mark', function () {
- $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('x', null, null, 'error')], [layoutNote('n', 99, '{N}')]);
- expect(layoutTags($out['content']))->toBe('比库入村托钵{N}<!error:x>');
- });
|