2
0

AnnotationLayoutTest.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. <?php
  2. use App\Services\AnnotationLayout;
  3. /**
  4. * 阅读页批注排版:glossary 替换、issue 高亮、note 插入,以及它们交叠时的处理。
  5. * 偏移一律是字符偏移(mb),区间半开 [start, end)。
  6. */
  7. function layoutGlossary(string $id, int $start, int $end, string $title): array
  8. {
  9. return compact('id', 'start', 'end', 'title');
  10. }
  11. function layoutIssue(string $id, ?int $start, ?int $end, ?string $severity = 'warning'): array
  12. {
  13. return compact('id', 'start', 'end', 'severity');
  14. }
  15. function layoutNote(string $id, int $at, string $html, array $order = [0]): array
  16. {
  17. return compact('id', 'at', 'html', 'order');
  18. }
  19. /** 把 issue 的开标签压成 <I:ids> 便于断言 */
  20. function layoutTags(string $html): string
  21. {
  22. $html = preg_replace('/<span class="anno-issue anno-issue-\w+" data-discussion-ids="([^"]*)" data-severity="\w*">/', '<I:$1>', $html);
  23. return preg_replace('/<span class="anno-issue-mark anno-issue-(\w+)" data-discussion-ids="([^"]*)".*?<\/i><\/span>/', '<!$1:$2>', $html);
  24. }
  25. // 原文:比库入村托钵(0 比 1 库 2 入 3 村 4 托 5 钵)
  26. const LAYOUT_TEXT = '比库入村托钵';
  27. /* ------------------------------------------------------------------ locate */
  28. it('locates by position when the quote agrees', function () {
  29. expect(AnnotationLayout::locate(LAYOUT_TEXT, 2, 4, '入村'))->toBe([2, 4]);
  30. });
  31. it('relocates by quote when the position is stale, preferring prefix/suffix, then the nearest', function () {
  32. $text = '村里有村,村外有村';
  33. // 位置失效(正文改过),按摘录找;有两处「有村」,后缀「外」只吻合第一处之后……这里用前缀「,村外」选第二处
  34. expect(AnnotationLayout::locate($text, 0, 1, '有村', '村外', ''))->toBe([7, 9])
  35. ->and(AnnotationLayout::locate($text, 6, 7, '有村'))->toBe([7, 9]) // 无前后缀:取离原位置最近的
  36. ->and(AnnotationLayout::locate($text, null, null, '有村'))->toBe([2, 4]) // 无位置:取第一处
  37. ->and(AnnotationLayout::locate($text, 0, 2, '没有'))->toBeNull()
  38. ->and(AnnotationLayout::locate($text, 3, 99, null))->toBeNull() // 越界且无摘录
  39. ->and(AnnotationLayout::locate($text, 3, 4, null))->toBe([3, 4]);
  40. });
  41. /* ------------------------------------------------------------------ glossary */
  42. it('replaces a glossary span with [[title#text]]', function () {
  43. $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g1', 0, 2, 'bhikkhu')], [], []);
  44. expect($out['content'])->toBe('[[bhikkhu#比库]]入村托钵')
  45. ->and($out['glossaries'])->toBe(['g1']);
  46. });
  47. it('keeps the outer glossary when one contains another, and the earlier one when they cross', function () {
  48. $contain = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('in', 1, 2, 'a'), layoutGlossary('out', 0, 4, 'b')], [], []);
  49. $cross = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('late', 3, 6, 'a'), layoutGlossary('early', 2, 4, 'b')], [], []);
  50. $touch = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('x', 0, 2, 'a'), layoutGlossary('y', 2, 4, 'b')], [], []);
  51. expect($contain['content'])->toBe('[[b#比库入村]]托钵')
  52. ->and($cross['content'])->toBe('比库[[b#入村]]托钵')
  53. ->and($touch['content'])->toBe('[[a#比库]][[b#入村]]托钵'); // 首尾相接不算交叠
  54. });
  55. it('drops a glossary that would cut existing markup or contains reserved characters', function () {
  56. $text = '缘起于[[sāvatthī]]。'; // 受保护区间 [3, 17)
  57. $out = AnnotationLayout::apply($text, [
  58. layoutGlossary('cut', 1, 5, 'x'), // 切进已有术语
  59. layoutGlossary('ok', 0, 2, 'y'),
  60. ], [], []);
  61. $bad = AnnotationLayout::apply('a|b c', [layoutGlossary('pipe', 0, 3, 't'), layoutGlossary('hash', 4, 5, 'a#b')], [], []);
  62. expect($out['content'])->toBe('[[y#缘起]]于[[sāvatthī]]。')
  63. ->and($bad['content'])->toBe('a|b c')
  64. ->and($bad['glossaries'])->toBe([]);
  65. });
  66. /* ------------------------------------------------------------------ issue */
  67. it('wraps an issue span with its id and severity class', function () {
  68. $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('i1', 2, 4, 'error')], []);
  69. expect($out['content'])->toBe('比库<span class="anno-issue anno-issue-error" data-discussion-ids="i1" data-severity="error">入村</span>托钵');
  70. });
  71. it('merges overlapping and nested issues, taking the union of ids and the heaviest severity', function () {
  72. $overlap = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 3, 'suggestion'), layoutIssue('b', 2, 5, 'error')], []);
  73. $nested = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('outer', 0, 6), layoutIssue('inner', 2, 3, 'error')], []);
  74. $chain = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('c', 4, 6), layoutIssue('b', 1, 5)], []);
  75. $touch = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('b', 2, 4)], []);
  76. expect(layoutTags($overlap['content']))->toBe('<I:a,b>比库入村托</span>钵')
  77. ->and($overlap['issues'][0]['severity'])->toBe('error')
  78. ->and(layoutTags($nested['content']))->toBe('<I:outer,inner>比库入村托钵</span>')
  79. ->and(layoutTags($chain['content']))->toBe('<I:a,b,c>比库入村托钵</span>') // 第三条把两条不相交的连起来
  80. ->and(layoutTags($touch['content']))->toBe('<I:a>比库</span><I:b>入村</span>托钵');
  81. });
  82. it('extends an issue to fully contain a glossary it partly overlaps', function () {
  83. // glossary 比库 [0,2);issue 库入 [1,3) 交叉 → 扩成 [0,3)
  84. $cross = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 'bhikkhu')], [layoutIssue('i', 1, 3)], []);
  85. // issue 在 glossary 内部 → 扩成与 glossary 相同
  86. $inside = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 4, 't')], [layoutIssue('i', 1, 2)], []);
  87. // issue 已包住 glossary → 不动
  88. $contains = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 2, 4, 't')], [layoutIssue('i', 1, 5)], []);
  89. expect(layoutTags($cross['content']))->toBe('<I:i>[[bhikkhu#比库]]入</span>村托钵')
  90. ->and(layoutTags($inside['content']))->toBe('<I:i>[[t#比库入村]]</span>托钵')
  91. ->and(layoutTags($contains['content']))->toBe('比<I:i>库[[t#入村]]托</span>钵');
  92. });
  93. it('merges issues that only meet after being extended over a glossary', function () {
  94. // glossary [2,4) 入村;issue a [0,3) 扩到 [0,4),issue b [3,6) 扩到 [2,6),两者因此交叠 → 合并
  95. $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 2, 4, 't')], [layoutIssue('a', 0, 3), layoutIssue('b', 3, 6)], []);
  96. expect(layoutTags($out['content']))->toBe('<I:a,b>比库[[t#入村]]托钵</span>');
  97. });
  98. it('extends an issue over existing markup instead of cutting it', function () {
  99. $text = '缘起于[[sāvatthī]]。';
  100. $out = AnnotationLayout::apply($text, [], [layoutIssue('i', 2, 6)], []);
  101. expect(layoutTags($out['content']))->toBe('缘起<I:i>于[[sāvatthī]]</span>。');
  102. });
  103. it('puts issues without a usable position into one end mark coloured by the heaviest severity', function () {
  104. $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [
  105. layoutIssue('a', null, null, 'suggestion'),
  106. layoutIssue('b', 3, 99, 'error'), // 越界当作没有位置
  107. layoutIssue('c', 0, 2, 'warning'),
  108. ], []);
  109. expect(layoutTags($out['content']))->toBe('<I:c>比库</span>入村托钵<!error:a,b>');
  110. });
  111. it('emits no issue markup when issues are turned off, but still replaces glossaries', function () {
  112. $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 'bhikkhu')], [layoutIssue('i', 0, 4), layoutIssue('j', null, null)], [], false);
  113. expect($out['content'])->toBe('[[bhikkhu#比库]]入村托钵');
  114. });
  115. /* ------------------------------------------------------------------ note */
  116. it('moves a note out of a glossary or existing markup, but leaves it inside an issue', function () {
  117. $out = AnnotationLayout::apply(LAYOUT_TEXT, [layoutGlossary('g', 0, 2, 't')], [layoutIssue('i', 2, 6)], [
  118. layoutNote('n1', 1, '{N1}'), // glossary 内部 → 挪到 2
  119. layoutNote('n2', 4, '{N2}'), // issue 内部 → 原地
  120. ]);
  121. $markup = AnnotationLayout::apply('缘起于[[sāvatthī]]。', [], [], [layoutNote('n', 5, '{N}')]);
  122. expect(layoutTags($out['content']))->toBe('[[t#比库]]{N1}<I:i>入村{N2}托钵</span>')
  123. ->and($markup['content'])->toBe('缘起于[[sāvatthī]]{N}。');
  124. });
  125. it('orders closing issue, notes, then opening issue at the same position', function () {
  126. $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('a', 0, 2), layoutIssue('b', 2, 4)], [
  127. layoutNote('late', 2, '{L}', [1]),
  128. layoutNote('early', 2, '{E}', [0]),
  129. ]);
  130. expect(layoutTags($out['content']))->toBe('<I:a>比库</span>{E}{L}<I:b>入村</span>托钵')
  131. ->and($out['notes'])->toBe(['early', 'late']);
  132. });
  133. it('clamps note positions into the sentence and appends end notes before the issue mark', function () {
  134. $out = AnnotationLayout::apply(LAYOUT_TEXT, [], [layoutIssue('x', null, null, 'error')], [layoutNote('n', 99, '{N}')]);
  135. expect(layoutTags($out['content']))->toBe('比库入村托钵{N}<!error:x>');
  136. });