Просмотр исходного кода

test(api-v13): 覆盖 glossary 与锚点规则、issue 公开可见、阅读页注入 glossary / issue 及交叠排版

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 18 часов назад
Родитель
Сommit
b2126c72cd

+ 87 - 10
api-v13/tests/Feature/DiscussionV3Test.php

@@ -73,19 +73,20 @@ it('lists only topics of the target, with reply counts and abilities', function
         ->and($json['meta']['total'])->toBe(1);
 });
 
-it('hides collaboration topics from anonymous viewers', function () {
+it('hides private topics from anonymous viewers, but not issues', function () {
     $target = makeTargetSentence(makeStudio('owner'));
     makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
-    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
+    $issue = makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
     makeDiscussion(['res_id' => $target, 'type' => 'qa']);
 
-    $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}")
+    $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&sort=created_at")
         ->assertOk()
         ->json('data');
 
-    expect($data)->toHaveCount(1)
-        ->and($data[0]['type'])->toBe('qa')
+    // issue 会注入公开的阅读页,所以对匿名也可见
+    expect(array_column($data, 'type'))->toEqualCanonicalizing(['issue', 'qa'])
         ->and($data[0]['abilities']['reply'])->toBeFalse();
+    $this->getJson("/api/v3/discussions/{$issue->id}")->assertOk();
 });
 
 it('shows a basic user only their own collaboration topics on targets they cannot manage', function () {
@@ -204,7 +205,7 @@ it('summarises status counts and creatable types', function () {
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
         ->assertOk()
         ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
-        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']);   // 句子的管理者能加批注
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']);   // 句子的管理者能加批注
 
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
         ->assertOk()
@@ -408,9 +409,11 @@ it('applies list visibility to batch counts', function () {
     makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $basic]);
     $body = ['targets' => [['type' => 'sentence', 'id' => $target]]];
 
-    $this->postJson('/api/v3/discussions/batch-count', $body)->assertJsonPath('data.0.total', 1);                       // 匿名:只数 qa
-    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($basic))->assertJsonPath('data.0.total', 2);  // basic:qa + 自己的 issue
-    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($owner))->assertJsonPath('data.0.total', 3);  // 管理者:全部
+    makeDiscussion(['res_id' => $target, 'type' => 'discussion', 'editor_uid' => $basic]);
+
+    $this->postJson('/api/v3/discussions/batch-count', $body)->assertJsonPath('data.0.total', 2);                       // 匿名:qa + issue(公开)
+    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($basic))->assertJsonPath('data.0.total', 3);  // basic:再加自己的 discussion
+    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($owner))->assertJsonPath('data.0.total', 4);  // 管理者:全部
 });
 
 it('rejects an invalid batch count request', function () {
@@ -475,7 +478,7 @@ it('lets only the sentence manager create annotations, without notifying', funct
         'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
 
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
-        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']);
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']);
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
         ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
 
@@ -547,6 +550,80 @@ it('still hides records that are neither threads nor annotations', function () {
     $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
 });
 
+/* ------------------------------------------------------------------ 术语讨论与锚点 */
+
+it('lets only the sentence manager create a glossary topic, with the headword as title and an anchor', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'glossary', 'title' => 'dukkha',
+        'content' => '这里译作「苦」不妥', 'pos_start' => 0, 'pos_end' => 6, 'quote_exact' => 'dukkha', 'notify' => false];
+
+    $this->postJson('/api/v3/discussions', $payload, authHeader(makeStudio('stranger')))->assertForbidden();
+
+    $id = $this->postJson('/api/v3/discussions', $payload, authHeader($owner))
+        ->assertCreated()
+        ->assertJsonPath('data.type', 'glossary')
+        ->assertJsonPath('data.title', 'dukkha')
+        ->assertJsonPath('data.status', 'active')
+        ->assertJsonPath('data.pos_end', 6)
+        ->assertJsonPath('data.quote_exact', 'dukkha')
+        ->json('data.id');
+
+    // 公开可见,算作评论线程(不带 type 的列表与角标都数它)
+    $this->getJson("/api/v3/discussions/{$id}")->assertOk();
+    $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}")
+        ->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $id);
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => [['type' => 'sentence', 'id' => $target]]])
+        ->assertJsonPath('data.0.total', 1);
+});
+
+it('validates glossary input', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $post = fn (array $data) => $this->postJson('/api/v3/discussions',
+        ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'glossary', 'notify' => false, ...$data], authHeader($owner));
+
+    $post(['content' => 'x'])->assertStatus(422)->assertJsonValidationErrors('title');
+    $post(['title' => 'dukkha', 'target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
+    $post(['title' => 'dukkha', 'severity' => 'error'])->assertStatus(422)->assertJsonValidationErrors('severity');
+});
+
+it('accepts an anchor on issue topics', function () {
+    $target = makeTargetSentence(makeStudio('owner'));
+    $author = makeStudio('author');
+
+    $id = $this->postJson('/api/v3/discussions', ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'issue',
+        'title' => '漏译', 'pos_start' => 2, 'pos_end' => 5, 'quote_exact' => 'nte', 'notify' => false], authHeader($author))
+        ->assertCreated()
+        ->assertJsonPath('data.pos_start', 2)
+        ->json('data.id');
+
+    $this->patchJson("/api/v3/discussions/{$id}", ['pos_end' => 7], authHeader($author))
+        ->assertOk()->assertJsonPath('data.pos_end', 7);
+});
+
+it('clears the anchor when a topic becomes a type without anchors, and guards conversion to glossary', function () {
+    $owner = makeStudio('owner');
+    $author = makeStudio('author');
+    $target = makeTargetSentence($owner);
+    $issue = makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $author, 'pos_start' => 1, 'pos_end' => 3]);
+    $onWbw = makeDiscussion(['res_type' => 'wbw', 'editor_uid' => $owner]);
+
+    $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'glossary'], authHeader($author))->assertForbidden();
+    $this->patchJson("/api/v3/discussions/{$onWbw->id}", ['type' => 'glossary'], authHeader($owner))
+        ->assertStatus(422)->assertJsonValidationErrors('type');
+    $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'discussion', 'pos_end' => 4], authHeader($author))
+        ->assertStatus(422)->assertJsonValidationErrors('pos_end');
+
+    $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'glossary'], authHeader($owner))
+        ->assertOk()->assertJsonPath('data.type', 'glossary')->assertJsonPath('data.pos_end', 3);
+    $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'discussion'], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.pos_start', null)
+        ->assertJsonPath('data.pos_end', null)
+        ->assertJsonPath('data.co_editor', null);
+});
+
 /* ------------------------------------------------------------------ 删除 */
 
 it('soft deletes a topic for its author and returns the tombstone', function () {

+ 71 - 0
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -95,3 +95,74 @@ it('drops the paragraph cache when either kind of annotation changes', function
     expect(RedisCache::has($key))->toBeFalse();
     expect($this->getJson($url)->json('data.0.display'))->toContain('译者按。');
 });
+
+/* ------------------------------------------------------------------ glossary 与 issue */
+
+function makeAnchored(string $resId, string $type, array $attrs): Discussion
+{
+    $discussion = new Discussion;
+    $discussion->forceFill(array_merge([
+        'res_id' => $resId,
+        'res_type' => 'sentence',
+        'type' => $type,
+        'title' => 't',
+        'content' => 'body',
+        'content_type' => 'markdown',
+        'editor_uid' => makeStudio('anno-'.Str::random(6)),
+    ], $attrs))->save();
+
+    return $discussion;
+}
+
+function readFirstParagraph(string $channel): string
+{
+    return test()->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")->assertOk()->json('data.0.display');
+}
+
+it('replaces a glossary anchor with a term reference', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();   // 'first sentence'
+    makeAnchored($sentence->uid, 'glossary', ['title' => 'paṭhama', 'pos_start' => 0, 'pos_end' => 5, 'quote_exact' => 'first']);
+
+    expect(readFirstParagraph($channel))
+        ->toContain("data-term='paṭhama'")
+        ->toContain('>first</span> sentence');
+});
+
+it('highlights issues, merging overlaps, and marks issues without a position at the sentence end', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    $a = makeAnchored($sentence->uid, 'issue', ['severity' => 'suggestion', 'pos_start' => 0, 'pos_end' => 8, 'quote_exact' => 'first se']);
+    $b = makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 6, 'pos_end' => 14, 'quote_exact' => 'sentence']);
+    $c = makeAnchored($sentence->uid, 'issue', ['severity' => 'warning']);
+
+    expect(readFirstParagraph($channel))
+        ->toContain('<span class="anno-issue anno-issue-error" data-discussion-ids="'.$a->id.','.$b->id.'" data-severity="error">first sentence</span>')
+        ->toContain('<span class="anno-issue-mark anno-issue-warning" data-discussion-ids="'.$c->id.'"');
+});
+
+it('relocates an issue by its quote when the position is stale', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 0, 'pos_end' => 3, 'quote_exact' => 'sentence']);
+
+    expect(readFirstParagraph($channel))->toContain('data-severity="error">sentence</span>');
+});
+
+it('does not inject closed issues or glossaries, and drops the cache when one is closed', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    makeAnchored($sentence->uid, 'glossary', ['title' => 'x', 'pos_start' => 0, 'pos_end' => 5, 'quote_exact' => 'first', 'status' => 'close']);
+    $issue = makeAnchored($sentence->uid, 'issue', ['severity' => 'error', 'pos_start' => 6, 'pos_end' => 14]);
+
+    expect(readFirstParagraph($channel))->toContain('anno-issue')->not->toContain('term-ref');
+
+    $issue->forceFill(['status' => 'close'])->save();   // 关闭会清缓存
+    expect(readFirstParagraph($channel))->not->toContain('anno-issue');
+});
+
+it('leaves issue markup out of plain text output but keeps the glossary term', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    makeAnchored($sentence->uid, 'issue', ['severity' => 'error']);
+    makeAnchored($sentence->uid, 'glossary', ['title' => 'paṭhama', 'pos_start' => 0, 'pos_end' => 5]);
+
+    $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
+
+    expect($text)->toBe('first sentence');
+});

+ 175 - 0
api-v13/tests/Unit/AnnotationLayoutTest.php

@@ -0,0 +1,175 @@
+<?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>');
+});