便于断言 */
function layoutTags(string $html): string
{
$html = preg_replace('//', '', $html);
return preg_replace('/<\/span>/', '', $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('比库入村托钵');
});
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('比库入村托钵')
->and($overlap['issues'][0]['severity'])->toBe('error')
->and(layoutTags($nested['content']))->toBe('比库入村托钵')
->and(layoutTags($chain['content']))->toBe('比库入村托钵') // 第三条把两条不相交的连起来
->and(layoutTags($touch['content']))->toBe('比库入村托钵');
});
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('[[bhikkhu#比库]]入村托钵')
->and(layoutTags($inside['content']))->toBe('[[t#比库入村]]托钵')
->and(layoutTags($contains['content']))->toBe('比库[[t#入村]]托钵');
});
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('比库[[t#入村]]托钵');
});
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('缘起于[[sāvatthī]]。');
});
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('比库入村托钵');
});
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}入村{N2}托钵')
->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('比库{E}{L}入村托钵')
->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}');
});