ReadingAnnotationsTest.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. <?php
  2. use App\Facades\RedisCache;
  3. use App\Models\Discussion;
  4. use App\Services\PaliContentService;
  5. use Illuminate\Foundation\Testing\RefreshDatabase;
  6. use Illuminate\Support\Str;
  7. uses(RefreshDatabase::class);
  8. /**
  9. * 阅读页批注内嵌:type='commentary'(注释书对应,带出处)与 type='note'(普通边注,无出处)。
  10. */
  11. /** 建 channel + 一句根本译文(book 9101 第 1 段),返回 [channel uid, 句子] */
  12. function makeAnnotatedSentence(): array
  13. {
  14. $channel = makeChannel(makeStudio('anno-owner'), 'anno channel');
  15. $sentence = makeSentence($channel, 9101, 1, 1, 'first sentence');
  16. return [$channel, $sentence];
  17. }
  18. function makeAnnotation(string $resId, string $type, string $content, int $posEnd): Discussion
  19. {
  20. $discussion = new Discussion;
  21. $discussion->forceFill([
  22. 'res_id' => $resId,
  23. 'res_type' => 'sentence',
  24. 'type' => $type,
  25. 'title' => $content,
  26. 'content' => $content,
  27. 'content_type' => 'markdown',
  28. 'editor_uid' => makeStudio('anno-'.Str::random(6)),
  29. 'pos_start' => 0,
  30. 'pos_end' => $posEnd,
  31. 'quote_exact' => 'first',
  32. ])->save();
  33. return $discussion;
  34. }
  35. it('injects a commentary annotation with a cite link', function () {
  36. [$channel, $sentence] = makeAnnotatedSentence();
  37. // 义注句:commentary 的 content 是它的句子模板,渲染成它在同 channel 的译文
  38. makeSentence($channel, 9102, 7, 3, '义注这么说');
  39. makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
  40. $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
  41. ->assertOk()
  42. ->json('data.0.display');
  43. expect($display)
  44. ->toContain('义注这么说')
  45. ->toContain('<cite class="anno-jump" data-book="9102" data-para="7" data-start="3" data-end="3">义注</cite>');
  46. });
  47. it('injects a plain note annotation without a cite link', function () {
  48. [$channel, $sentence] = makeAnnotatedSentence();
  49. // 普通边注:content 就是注解正文本身
  50. makeAnnotation($sentence->uid, 'note', '这里的 dassana 指见到佛陀。', 5);
  51. $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
  52. ->assertOk()
  53. ->json('data.0.display');
  54. // 正文照原样进 sidenote,紧跟 </span> 收尾——没有 <cite> 出处
  55. expect($display)
  56. ->toContain('这里的 dassana 指见到佛陀。</span>')
  57. ->not->toContain('anno-jump');
  58. });
  59. it('injects both kinds on the same sentence, note first at the same position', function () {
  60. [$channel, $sentence] = makeAnnotatedSentence();
  61. makeSentence($channel, 9102, 7, 3, '义注这么说');
  62. makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
  63. makeAnnotation($sentence->uid, 'note', '译者按。', 5);
  64. $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
  65. ->assertOk()
  66. ->json('data.0.display');
  67. expect(mb_strpos($display, '译者按。'))->toBeLessThan(mb_strpos($display, '义注这么说'));
  68. });
  69. it('drops the paragraph cache when either kind of annotation changes', function () {
  70. [$channel, $sentence] = makeAnnotatedSentence();
  71. $url = "/api/v3/tipitaka-reading/{$channel}?book=9101&para=1";
  72. $this->getJson($url)->assertOk();
  73. $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
  74. expect(RedisCache::has($key))->toBeTrue();
  75. makeAnnotation($sentence->uid, 'note', '译者按。', 5);
  76. expect(RedisCache::has($key))->toBeFalse();
  77. expect($this->getJson($url)->json('data.0.display'))->toContain('译者按。');
  78. });
  79. /* ------------------------------------------------------------------ glossary 与 issue */
  80. function makeAnchored(string $resId, string $type, array $attrs): Discussion
  81. {
  82. $discussion = new Discussion;
  83. $discussion->forceFill(array_merge([
  84. 'res_id' => $resId,
  85. 'res_type' => 'sentence',
  86. 'type' => $type,
  87. 'title' => 't',
  88. 'content' => 'body',
  89. 'content_type' => 'markdown',
  90. 'editor_uid' => makeStudio('anno-'.Str::random(6)),
  91. ], $attrs))->save();
  92. return $discussion;
  93. }
  94. function readFirstParagraph(string $channel): string
  95. {
  96. return test()->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")->assertOk()->json('data.0.display');
  97. }
  98. it('replaces a glossary anchor with a term reference', function () {
  99. [$channel, $sentence] = makeAnnotatedSentence(); // 'first sentence'
  100. makeAnchored($sentence->uid, 'glossary', ['title' => 'paṭhama', 'pos_start' => 0, 'pos_end' => 5, 'quote_exact' => 'first']);
  101. expect(readFirstParagraph($channel))
  102. ->toContain("data-term='paṭhama'")
  103. ->toContain('>first</span> sentence');
  104. });
  105. it('highlights issues, merging overlaps, and marks issues without a position at the sentence end', function () {
  106. [$channel, $sentence] = makeAnnotatedSentence();
  107. $a = makeAnchored($sentence->uid, 'issue', ['severity' => 'suggestion', 'pos_start' => 0, 'pos_end' => 8, 'quote_exact' => 'first se']);
  108. $b = makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 6, 'pos_end' => 14, 'quote_exact' => 'sentence']);
  109. $c = makeAnchored($sentence->uid, 'issue', ['severity' => 'warning']);
  110. expect(readFirstParagraph($channel))
  111. ->toContain('<span class="anno-issue anno-issue-error" data-discussion-ids="'.$a->id.','.$b->id.'" data-severity="error">first sentence</span>')
  112. ->toContain('<span class="anno-issue-mark anno-issue-warning" data-discussion-ids="'.$c->id.'"');
  113. });
  114. it('relocates an issue by its quote when the position is stale', function () {
  115. [$channel, $sentence] = makeAnnotatedSentence();
  116. makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 0, 'pos_end' => 3, 'quote_exact' => 'sentence']);
  117. expect(readFirstParagraph($channel))->toContain('data-severity="error">sentence</span>');
  118. });
  119. it('does not inject closed issues or glossaries, and drops the cache when one is closed', function () {
  120. [$channel, $sentence] = makeAnnotatedSentence();
  121. makeAnchored($sentence->uid, 'glossary', ['title' => 'x', 'pos_start' => 0, 'pos_end' => 5, 'quote_exact' => 'first', 'status' => 'close']);
  122. $issue = makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 6, 'pos_end' => 14]);
  123. expect(readFirstParagraph($channel))->toContain('anno-issue')->not->toContain('term-ref');
  124. $issue->forceFill(['status' => 'close'])->save(); // 关闭会清缓存
  125. expect(readFirstParagraph($channel))->not->toContain('anno-issue');
  126. });
  127. it('leaves issue markup out of plain text output but keeps the glossary term', function () {
  128. [$channel, $sentence] = makeAnnotatedSentence();
  129. makeAnchored($sentence->uid, 'issue', ['severity' => 'error']);
  130. makeAnchored($sentence->uid, 'glossary', ['title' => 'paṭhama', 'pos_start' => 0, 'pos_end' => 5]);
  131. $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
  132. expect($text)->toBe('first sentence');
  133. });
  134. /* ------------------------------------------------------------------ 评估 */
  135. it('marks an assessed sentence with a check coloured by score, after any issue mark', function (float $score, string $level) {
  136. [$channel, $sentence] = makeAnnotatedSentence();
  137. makeAnchored($sentence->uid, 'issue', ['severity' => 'warning']);
  138. $assessment = makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => $score]);
  139. $display = readFirstParagraph($channel);
  140. expect($display)
  141. ->toContain('<span class="anno-assessment-mark anno-assessment-'.$level.'" data-discussion-ids="'.$assessment->id.'"')
  142. ->toContain('data-level="'.$level.'"')
  143. ->toContain('<i class="ti ti-circle-check"></i>')
  144. ->and(mb_strpos($display, 'anno-issue-mark'))->toBeLessThan(mb_strpos($display, 'anno-assessment-mark'));
  145. })->with([
  146. 'high' => [80.0, 'high'],
  147. 'medium' => [79.99, 'medium'],
  148. 'low' => [12.5, 'low'],
  149. ]);
  150. it('drops the cache when an assessment is rescored, and leaves it out of plain text', function () {
  151. [$channel, $sentence] = makeAnnotatedSentence();
  152. $assessment = makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => 30]);
  153. expect(readFirstParagraph($channel))->toContain('data-score="30"');
  154. $assessment->forceFill(['score' => 95.5])->save();
  155. expect(readFirstParagraph($channel))->toContain('data-score="95.5"')->toContain('anno-assessment-high');
  156. $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
  157. expect($text)->toBe('first sentence');
  158. });
  159. it('marks unassessed sentences grey only in a paragraph that has an assessed sentence', function () {
  160. [$channel, $sentence] = makeAnnotatedSentence(); // 9101-1,第一句
  161. makeSentence($channel, 9101, 1, 2, 'second sentence');
  162. makeSentence($channel, 9101, 2, 1, 'other paragraph');
  163. makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => 90]);
  164. $display = readFirstParagraph($channel);
  165. expect(substr_count($display, 'anno-assessment-high'))->toBe(1)
  166. ->and(substr_count($display, 'anno-assessment-none'))->toBe(1)
  167. ->and($display)->toContain('second sentence<span class="anno-assessment-mark anno-assessment-none"');
  168. $other = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=2")->assertOk()->json('data.0.display');
  169. expect($other)->not->toContain('anno-assessment');
  170. $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
  171. expect($text)->not->toContain('anno-assessment');
  172. });