test: markdown parser subsystem (58) + custom-emoji readers (32)
Via subagents, probe-verified against real output, no bugs: - markdown: internal/utils (11), inline/runner (7), inline/parser (21 — bold/ italic/underline/strike/code/spoiler/link, nesting, precedence, URL lookbehind), block/parser (19 — headings/code-fences/quotes/lists/<br>/escapes). Closes the biggest coverage hole (core message rendering). - custom-emoji: PackMetaReader (6), PackImageReader (7), PackImagesReader (4), utils equality+makeImagePacks (5), recent-emoji promote/increment/100-cap (10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PackImageReader } from './PackImageReader';
|
||||
import { ImageUsage, PackImage } from './types';
|
||||
|
||||
test('fromPackImage returns undefined when url is missing or not a string', () => {
|
||||
assert.equal(PackImageReader.fromPackImage('cat', {} as unknown as PackImage), undefined);
|
||||
assert.equal(
|
||||
PackImageReader.fromPackImage('cat', { url: 123 } as unknown as PackImage),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('fromPackImage builds a reader with shortcode and url', () => {
|
||||
const reader = PackImageReader.fromPackImage('cat', { url: 'mxc://x/cat' });
|
||||
assert.ok(reader);
|
||||
assert.equal(reader?.shortcode, 'cat');
|
||||
assert.equal(reader?.url, 'mxc://x/cat');
|
||||
});
|
||||
|
||||
test('body reads only string values', () => {
|
||||
assert.equal(PackImageReader.fromPackImage('c', { url: 'u', body: 'Cat' })?.body, 'Cat');
|
||||
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.body, undefined);
|
||||
assert.equal(
|
||||
PackImageReader.fromPackImage('c', { url: 'u', body: 5 } as unknown as PackImage)?.body,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('info is passed through unchanged', () => {
|
||||
const info = { w: 10, h: 10 };
|
||||
assert.equal(
|
||||
PackImageReader.fromPackImage('c', { url: 'u', info } as unknown as PackImage)?.info,
|
||||
info,
|
||||
);
|
||||
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.info, undefined);
|
||||
});
|
||||
|
||||
test('usage filters to known values and returns undefined when none/non-array', () => {
|
||||
assert.equal(PackImageReader.fromPackImage('c', { url: 'u' })?.usage, undefined);
|
||||
assert.equal(
|
||||
PackImageReader.fromPackImage('c', {
|
||||
url: 'u',
|
||||
usage: 'emoticon' as unknown as ImageUsage[],
|
||||
})?.usage,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
PackImageReader.fromPackImage('c', {
|
||||
url: 'u',
|
||||
usage: ['bogus'] as unknown as ImageUsage[],
|
||||
})?.usage,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
PackImageReader.fromPackImage('c', { url: 'u', usage: [ImageUsage.Sticker] })?.usage,
|
||||
[ImageUsage.Sticker],
|
||||
);
|
||||
assert.deepEqual(
|
||||
PackImageReader.fromPackImage('c', {
|
||||
url: 'u',
|
||||
usage: [ImageUsage.Emoticon, 'x', ImageUsage.Sticker] as unknown as ImageUsage[],
|
||||
})?.usage,
|
||||
[ImageUsage.Emoticon, ImageUsage.Sticker],
|
||||
);
|
||||
});
|
||||
|
||||
test('content reconstructs the PackImage from url and raw image fields', () => {
|
||||
const info = { w: 1, h: 1 };
|
||||
const reader = PackImageReader.fromPackImage('c', {
|
||||
url: 'u',
|
||||
body: 'Cat',
|
||||
usage: [ImageUsage.Emoticon],
|
||||
info,
|
||||
} as unknown as PackImage);
|
||||
assert.deepEqual(reader?.content, {
|
||||
url: 'u',
|
||||
body: 'Cat',
|
||||
usage: [ImageUsage.Emoticon],
|
||||
info,
|
||||
});
|
||||
});
|
||||
|
||||
test('content preserves raw usage even when usage getter filters it', () => {
|
||||
// content uses the raw image.usage, not the filtered usage getter
|
||||
const reader = PackImageReader.fromPackImage('c', {
|
||||
url: 'u',
|
||||
usage: ['bogus'] as unknown as ImageUsage[],
|
||||
});
|
||||
assert.deepEqual(reader?.content.usage, ['bogus'] as unknown as ImageUsage[]);
|
||||
assert.equal(reader?.usage, undefined);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PackImagesReader } from './PackImagesReader';
|
||||
import { PackImages, ImageUsage } from './types';
|
||||
|
||||
test('collection builds a shortcode -> reader map', () => {
|
||||
const images: PackImages = {
|
||||
cat: { url: 'mxc://x/cat', usage: [ImageUsage.Emoticon] },
|
||||
dog: { url: 'mxc://x/dog' },
|
||||
};
|
||||
const reader = new PackImagesReader(images);
|
||||
const collection = reader.collection;
|
||||
|
||||
assert.equal(collection.size, 2);
|
||||
assert.equal(collection.get('cat')?.url, 'mxc://x/cat');
|
||||
assert.equal(collection.get('cat')?.shortcode, 'cat');
|
||||
assert.equal(collection.get('dog')?.url, 'mxc://x/dog');
|
||||
});
|
||||
|
||||
test('collection drops invalid images (missing/non-string url)', () => {
|
||||
const images = {
|
||||
good: { url: 'mxc://x/good' },
|
||||
noUrl: {},
|
||||
badUrl: { url: 5 },
|
||||
} as unknown as PackImages;
|
||||
const collection = new PackImagesReader(images).collection;
|
||||
|
||||
assert.equal(collection.size, 1);
|
||||
assert.ok(collection.has('good'));
|
||||
assert.equal(collection.has('noUrl'), false);
|
||||
assert.equal(collection.has('badUrl'), false);
|
||||
});
|
||||
|
||||
test('collection of an empty image set is an empty map', () => {
|
||||
const collection = new PackImagesReader({}).collection;
|
||||
assert.equal(collection.size, 0);
|
||||
});
|
||||
|
||||
test('collection is memoized (same Map instance returned)', () => {
|
||||
const reader = new PackImagesReader({ cat: { url: 'u' } });
|
||||
const first = reader.collection;
|
||||
const second = reader.collection;
|
||||
assert.equal(first, second);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PackMetaReader } from './PackMetaReader';
|
||||
import { ImageUsage, PackMeta } from './types';
|
||||
|
||||
const reader = (meta: PackMeta) => new PackMetaReader(meta);
|
||||
|
||||
test('name reads display_name only when it is a string', () => {
|
||||
assert.equal(reader({ display_name: 'Cats' }).name, 'Cats');
|
||||
assert.equal(reader({}).name, undefined);
|
||||
assert.equal(reader({ display_name: 42 } as unknown as PackMeta).name, undefined);
|
||||
});
|
||||
|
||||
test('avatar reads avatar_url only when it is a string', () => {
|
||||
assert.equal(reader({ avatar_url: 'mxc://x/y' }).avatar, 'mxc://x/y');
|
||||
assert.equal(reader({}).avatar, undefined);
|
||||
assert.equal(reader({ avatar_url: {} } as unknown as PackMeta).avatar, undefined);
|
||||
});
|
||||
|
||||
test('attribution reads attribution only when it is a string', () => {
|
||||
assert.equal(reader({ attribution: 'me' }).attribution, 'me');
|
||||
assert.equal(reader({}).attribution, undefined);
|
||||
assert.equal(reader({ attribution: 0 } as unknown as PackMeta).attribution, undefined);
|
||||
});
|
||||
|
||||
test('usage falls back when missing, non-array, or empty/unknown', () => {
|
||||
const fallback = [ImageUsage.Emoticon, ImageUsage.Sticker];
|
||||
assert.deepEqual(reader({}).usage, fallback);
|
||||
assert.deepEqual(reader({ usage: 'emoticon' } as unknown as PackMeta).usage, fallback);
|
||||
assert.deepEqual(reader({ usage: [] }).usage, fallback);
|
||||
assert.deepEqual(reader({ usage: ['bogus'] as unknown as ImageUsage[] }).usage, fallback);
|
||||
});
|
||||
|
||||
test('usage filters to only known values', () => {
|
||||
assert.deepEqual(reader({ usage: [ImageUsage.Emoticon] }).usage, [ImageUsage.Emoticon]);
|
||||
assert.deepEqual(reader({ usage: [ImageUsage.Sticker] }).usage, [ImageUsage.Sticker]);
|
||||
assert.deepEqual(
|
||||
reader({
|
||||
usage: [ImageUsage.Emoticon, 'bogus', ImageUsage.Sticker] as unknown as ImageUsage[],
|
||||
}).usage,
|
||||
[ImageUsage.Emoticon, ImageUsage.Sticker],
|
||||
);
|
||||
});
|
||||
|
||||
test('content returns the original meta object', () => {
|
||||
const meta: PackMeta = { display_name: 'Cats', usage: [ImageUsage.Emoticon] };
|
||||
assert.equal(reader(meta).content, meta);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { packAddressEqual, imageUsageEqual, packMetaEqual, makeImagePacks } from './utils';
|
||||
import { PackAddress } from './PackAddress';
|
||||
import { PackMetaReader } from './PackMetaReader';
|
||||
import { ImageUsage, PackContent, PackMeta } from './types';
|
||||
|
||||
test('packAddressEqual handles undefined and value equality', () => {
|
||||
assert.equal(packAddressEqual(undefined, undefined), true);
|
||||
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), undefined), false);
|
||||
assert.equal(packAddressEqual(undefined, new PackAddress('!r', 'k')), false);
|
||||
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!r', 'k')), true);
|
||||
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!r', 'j')), false);
|
||||
assert.equal(packAddressEqual(new PackAddress('!r', 'k'), new PackAddress('!s', 'k')), false);
|
||||
});
|
||||
|
||||
test('imageUsageEqual compares set membership and length', () => {
|
||||
assert.equal(imageUsageEqual([], []), true);
|
||||
assert.equal(imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Emoticon]), true);
|
||||
assert.equal(
|
||||
imageUsageEqual(
|
||||
[ImageUsage.Emoticon, ImageUsage.Sticker],
|
||||
[ImageUsage.Sticker, ImageUsage.Emoticon],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Sticker]), false);
|
||||
assert.equal(
|
||||
imageUsageEqual([ImageUsage.Emoticon], [ImageUsage.Emoticon, ImageUsage.Sticker]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const meta = (m: PackMeta) => new PackMetaReader(m);
|
||||
|
||||
test('packMetaEqual compares name, avatar, attribution and usage', () => {
|
||||
assert.equal(packMetaEqual(meta({ display_name: 'A' }), meta({ display_name: 'A' })), true);
|
||||
assert.equal(packMetaEqual(meta({ display_name: 'A' }), meta({ display_name: 'B' })), false);
|
||||
assert.equal(packMetaEqual(meta({ avatar_url: 'u' }), meta({ avatar_url: 'v' })), false);
|
||||
assert.equal(packMetaEqual(meta({ attribution: 'x' }), meta({ attribution: 'y' })), false);
|
||||
// both fall back to default usage => equal
|
||||
assert.equal(packMetaEqual(meta({}), meta({})), true);
|
||||
assert.equal(
|
||||
packMetaEqual(meta({ usage: [ImageUsage.Emoticon] }), meta({ usage: [ImageUsage.Sticker] })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const eventStub = (id: string | undefined, content: PackContent = {}): MatrixEvent =>
|
||||
({
|
||||
getId: () => id,
|
||||
getRoomId: () => '!room:server',
|
||||
getStateKey: () => 'state-key',
|
||||
getContent: () => content,
|
||||
}) as unknown as MatrixEvent;
|
||||
|
||||
test('makeImagePacks builds packs and skips events without an id', () => {
|
||||
const events = [
|
||||
eventStub('$1', { pack: { display_name: 'One' } }),
|
||||
eventStub(undefined, { pack: { display_name: 'Skip' } }),
|
||||
eventStub('$2', { images: { cat: { url: 'mxc://x/cat' } } }),
|
||||
];
|
||||
const packs = makeImagePacks(events);
|
||||
|
||||
assert.equal(packs.length, 2);
|
||||
assert.equal(packs[0].id, '$1');
|
||||
assert.equal(packs[0].meta.name, 'One');
|
||||
assert.equal(packs[0].address?.roomId, '!room:server');
|
||||
assert.equal(packs[0].address?.stateKey, 'state-key');
|
||||
assert.equal(packs[1].id, '$2');
|
||||
assert.equal(packs[1].images.collection.get('cat')?.url, 'mxc://x/cat');
|
||||
});
|
||||
|
||||
test('makeImagePacks returns empty for empty input', () => {
|
||||
assert.deepEqual(makeImagePacks([]), []);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseBlockMD } from './parser';
|
||||
import { parseInlineMD } from '../inline/parser';
|
||||
|
||||
test('empty string is returned unchanged', () => {
|
||||
assert.equal(parseBlockMD('', parseInlineMD), '');
|
||||
});
|
||||
|
||||
test('plain single line is returned unchanged', () => {
|
||||
assert.equal(parseBlockMD('hello', parseInlineMD), 'hello');
|
||||
});
|
||||
|
||||
test('heading levels', () => {
|
||||
assert.equal(parseBlockMD('# Heading', parseInlineMD), '<h1 data-md="#">Heading</h1>');
|
||||
assert.equal(parseBlockMD('### Three', parseInlineMD), '<h3 data-md="###">Three</h3>');
|
||||
});
|
||||
|
||||
test('heading requires a space after the hashes', () => {
|
||||
// no space => not a heading, falls through to plain text
|
||||
assert.equal(parseBlockMD('#nospace', parseInlineMD), '#nospace');
|
||||
});
|
||||
|
||||
test('inline markdown inside a heading is parsed', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('# **b**', parseInlineMD),
|
||||
'<h1 data-md="#"><strong data-md="**">b</strong></h1>',
|
||||
);
|
||||
});
|
||||
|
||||
test('heading without a parseInline function keeps the raw text', () => {
|
||||
assert.equal(parseBlockMD('# Heading', undefined), '<h1 data-md="#">Heading</h1>');
|
||||
});
|
||||
|
||||
test('fenced code block without info string', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('```\ncode\n```', parseInlineMD),
|
||||
'<pre data-md="```"><code>code\n</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test('fenced code block with a language', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('```js\ncode\n```', parseInlineMD),
|
||||
'<pre data-md="```"><code class="language-js">code\n</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test('fenced code block with a filename adds language and data-label', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('```example.json\ncode\n```', parseInlineMD),
|
||||
'<pre data-md="```"><code class="language-json" data-label="example.json">code\n</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test('blockquote single line', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('> quote', parseInlineMD),
|
||||
'<blockquote data-md=">">quote<br/></blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
test('blockquote multiple lines', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('> a\n> b', parseInlineMD),
|
||||
'<blockquote data-md=">">a<br/>b<br/></blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
test('unordered list', () => {
|
||||
assert.equal(parseBlockMD('* item', parseInlineMD), '<ul data-md="*"><li><p>item</p></li></ul>');
|
||||
});
|
||||
|
||||
test('ordered list', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('1. item', parseInlineMD),
|
||||
'<ol data-md="1" start="1"><li><p>item</p></li></ol>',
|
||||
);
|
||||
});
|
||||
|
||||
test('list with multiple items', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('* a\n* b', parseInlineMD),
|
||||
'<ul data-md="*"><li><p>a</p></li><li><p>b</p></li></ul>',
|
||||
);
|
||||
});
|
||||
|
||||
test('nested list opens a child list', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('* a\n * b', parseInlineMD),
|
||||
'<ul data-md="*"><li><p>a</p><ul data-md="*"><li><p>b</p></li></ul></ul>',
|
||||
);
|
||||
});
|
||||
|
||||
test('inline markdown inside list items is parsed', () => {
|
||||
assert.equal(
|
||||
parseBlockMD('1. **b**', parseInlineMD),
|
||||
'<ol data-md="1" start="1"><li><p><strong data-md="**">b</strong></p></li></ol>',
|
||||
);
|
||||
});
|
||||
|
||||
test('newlines are preserved as <br/>', () => {
|
||||
assert.equal(parseBlockMD('line1\nline2', parseInlineMD), 'line1<br/>line2');
|
||||
});
|
||||
|
||||
test('empty lines are preserved as <br/>', () => {
|
||||
assert.equal(parseBlockMD('a\n\nb', parseInlineMD), 'a<br/><br/>b');
|
||||
});
|
||||
|
||||
test('escaped block sequence is unescaped and not treated as a block', () => {
|
||||
assert.equal(parseBlockMD('\\# not heading', parseInlineMD), '# not heading');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseInlineMD } from './parser';
|
||||
|
||||
test('empty string is returned unchanged', () => {
|
||||
assert.equal(parseInlineMD(''), '');
|
||||
});
|
||||
|
||||
test('plain text without markdown is returned unchanged', () => {
|
||||
assert.equal(parseInlineMD('hello world'), 'hello world');
|
||||
});
|
||||
|
||||
test('bold', () => {
|
||||
assert.equal(parseInlineMD('**bold**'), '<strong data-md="**">bold</strong>');
|
||||
});
|
||||
|
||||
test('italic with asterisk', () => {
|
||||
assert.equal(parseInlineMD('*italic*'), '<i data-md="*">italic</i>');
|
||||
});
|
||||
|
||||
test('italic with underscore', () => {
|
||||
assert.equal(parseInlineMD('_italic_'), '<i data-md="_">italic</i>');
|
||||
});
|
||||
|
||||
test('underline', () => {
|
||||
assert.equal(parseInlineMD('__under__'), '<u data-md="__">under</u>');
|
||||
});
|
||||
|
||||
test('strikethrough', () => {
|
||||
assert.equal(parseInlineMD('~~strike~~'), '<s data-md="~~">strike</s>');
|
||||
});
|
||||
|
||||
test('inline code', () => {
|
||||
assert.equal(parseInlineMD('`code`'), '<code data-md="`">code</code>');
|
||||
});
|
||||
|
||||
test('inline code does not parse markdown inside it', () => {
|
||||
// code is run before the other rules and does not re-parse its content
|
||||
assert.equal(parseInlineMD('`**bold**`'), '<code data-md="`">**bold**</code>');
|
||||
});
|
||||
|
||||
test('spoiler', () => {
|
||||
assert.equal(parseInlineMD('||secret||'), '<span data-md="||" data-mx-spoiler>secret</span>');
|
||||
});
|
||||
|
||||
test('link', () => {
|
||||
assert.equal(
|
||||
parseInlineMD('[alt](https://example.com)'),
|
||||
'<a data-md href="https://example.com">alt</a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('escaped markdown characters are unescaped to literal text', () => {
|
||||
assert.equal(parseInlineMD('\\*notbold\\*'), '*notbold*');
|
||||
assert.equal(parseInlineMD('a\\_b'), 'a_b');
|
||||
});
|
||||
|
||||
test('nesting: italic inside bold', () => {
|
||||
assert.equal(
|
||||
parseInlineMD('**bold *italic***'),
|
||||
'<strong data-md="**">bold <i data-md="*">italic</i></strong>',
|
||||
);
|
||||
});
|
||||
|
||||
test('nesting: bold inside link alt text', () => {
|
||||
assert.equal(
|
||||
parseInlineMD('[**b**](https://e.com)'),
|
||||
'<a data-md href="https://e.com"><strong data-md="**">b</strong></a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('nesting: bold inside spoiler', () => {
|
||||
assert.equal(
|
||||
parseInlineMD('||**b**||'),
|
||||
'<span data-md="||" data-mx-spoiler><strong data-md="**">b</strong></span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('adjacent tokens of different types are both parsed', () => {
|
||||
assert.equal(parseInlineMD('**a**_b_'), '<strong data-md="**">a</strong><i data-md="_">b</i>');
|
||||
});
|
||||
|
||||
test('text surrounding a token is preserved', () => {
|
||||
assert.equal(parseInlineMD('pre **mid** post'), 'pre <strong data-md="**">mid</strong> post');
|
||||
});
|
||||
|
||||
test('two separate tokens are parsed in their text order', () => {
|
||||
assert.equal(parseInlineMD('*a* **b**'), '<i data-md="*">a</i> <strong data-md="**">b</strong>');
|
||||
assert.equal(parseInlineMD('__a__ _b_'), '<u data-md="__">a</u> <i data-md="_">b</i>');
|
||||
});
|
||||
|
||||
test('code takes precedence and is resolved before other inline rules', () => {
|
||||
assert.equal(parseInlineMD('`a` *b*'), '<code data-md="`">a</code> <i data-md="*">b</i>');
|
||||
});
|
||||
|
||||
test('unclosed token is returned as literal text', () => {
|
||||
assert.equal(parseInlineMD('**unclosed'), '**unclosed');
|
||||
});
|
||||
|
||||
test('markdown characters inside a URL are not parsed (negative lookbehind)', () => {
|
||||
assert.equal(parseInlineMD('https://e.com/*path*'), 'https://e.com/*path*');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runInlineRule, runInlineRules } from './runner';
|
||||
import { parseInlineMD } from './parser';
|
||||
import { BoldRule, ItalicRule1, StrikeRule } from './rules';
|
||||
import { InlineMDRule } from './type';
|
||||
|
||||
// A trivial rule that wraps the matched token in <X>...</X>.
|
||||
const makeRule = (token: string, tag: string): InlineMDRule => ({
|
||||
match: (text) => text.match(new RegExp(token)),
|
||||
html: (parse, match) => `<${tag}>${parse(match[0])}</${tag}>`,
|
||||
});
|
||||
|
||||
test('runInlineRule applies a matching rule', () => {
|
||||
assert.equal(runInlineRule('**b**', BoldRule, parseInlineMD), '<strong data-md="**">b</strong>');
|
||||
});
|
||||
|
||||
test('runInlineRule returns undefined when the rule does not match', () => {
|
||||
assert.equal(runInlineRule('plain', BoldRule, parseInlineMD), undefined);
|
||||
});
|
||||
|
||||
test('runInlineRule recursively parses surrounding text', () => {
|
||||
// bold matches in the middle; text on both sides is re-parsed (here it is plain)
|
||||
assert.equal(
|
||||
runInlineRule('pre **mid** post', BoldRule, parseInlineMD),
|
||||
'pre <strong data-md="**">mid</strong> post',
|
||||
);
|
||||
});
|
||||
|
||||
test('runInlineRules returns undefined when no rule matches', () => {
|
||||
assert.equal(runInlineRules('plain text', [BoldRule, ItalicRule1], parseInlineMD), undefined);
|
||||
});
|
||||
|
||||
test('runInlineRules picks the earliest-matching rule regardless of rule order', () => {
|
||||
// italic appears before bold in the text, so italic wins even though Bold is listed first
|
||||
assert.equal(
|
||||
runInlineRules('a *i* **b**', [BoldRule, ItalicRule1], parseInlineMD),
|
||||
'a <i data-md="*">i</i> <strong data-md="**">b</strong>',
|
||||
);
|
||||
});
|
||||
|
||||
test('runInlineRules breaks index ties by rule order (first listed wins)', () => {
|
||||
// Two synthetic rules both match at index 0; the first in the list wins.
|
||||
const aRule = makeRule('a', 'A');
|
||||
const a2Rule = makeRule('a', 'B');
|
||||
assert.equal(runInlineRules('a', [aRule, a2Rule], parseInlineMD), '<A>a</A>');
|
||||
assert.equal(runInlineRules('a', [a2Rule, aRule], parseInlineMD), '<B>a</B>');
|
||||
});
|
||||
|
||||
test('runInlineRules earliest index wins even when a later-listed rule matches sooner', () => {
|
||||
// Strike token "~~s~~" is at index 0; bold token is later in the string.
|
||||
assert.equal(
|
||||
runInlineRules('~~s~~ **b**', [BoldRule, StrikeRule], parseInlineMD),
|
||||
'<s data-md="~~">s</s> <strong data-md="**">b</strong>',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { beforeMatch, afterMatch, replaceMatch } from './utils';
|
||||
|
||||
test('beforeMatch returns the slice before the match', () => {
|
||||
const match = 'abXYcd'.match(/XY/)!;
|
||||
assert.equal(beforeMatch('abXYcd', match), 'ab');
|
||||
});
|
||||
|
||||
test('beforeMatch is empty when the match is at the start', () => {
|
||||
const match = 'XYcd'.match(/XY/)!;
|
||||
assert.equal(beforeMatch('XYcd', match), '');
|
||||
});
|
||||
|
||||
test('beforeMatch on a match without an index treats index as undefined', () => {
|
||||
// text.slice(0, undefined) returns the whole string
|
||||
const fakeMatch = ['X'] as unknown as RegExpMatchArray;
|
||||
assert.equal(beforeMatch('Xabc', fakeMatch), 'Xabc');
|
||||
});
|
||||
|
||||
test('afterMatch returns the slice after the match', () => {
|
||||
const match = 'abXYcd'.match(/XY/)!;
|
||||
assert.equal(afterMatch('abXYcd', match), 'cd');
|
||||
});
|
||||
|
||||
test('afterMatch is empty when the match runs to the end', () => {
|
||||
const match = 'abXY'.match(/XY/)!;
|
||||
assert.equal(afterMatch('abXY', match), '');
|
||||
});
|
||||
|
||||
test('afterMatch handles a match at the start', () => {
|
||||
const match = 'XYcd'.match(/XY/)!;
|
||||
assert.equal(afterMatch('XYcd', match), 'cd');
|
||||
});
|
||||
|
||||
test('afterMatch falls back to index 0 when match.index is missing', () => {
|
||||
// (undefined ?? 0) + match[0].length === 1, so it slices off the first char
|
||||
const fakeMatch = ['X'] as unknown as RegExpMatchArray;
|
||||
assert.equal(afterMatch('Xabc', fakeMatch), 'abc');
|
||||
});
|
||||
|
||||
test('replaceMatch splices content between processed before/after parts', () => {
|
||||
const match = 'abXYcd'.match(/XY/)!;
|
||||
assert.deepEqual(
|
||||
replaceMatch('abXYcd', match, '<R>', (t) => [t]),
|
||||
['ab', '<R>', 'cd'],
|
||||
);
|
||||
});
|
||||
|
||||
test('replaceMatch applies processPart to the surrounding text', () => {
|
||||
const match = 'abXYcd'.match(/XY/)!;
|
||||
assert.deepEqual(
|
||||
replaceMatch('abXYcd', match, '<R>', (t) => [t.toUpperCase()]),
|
||||
['AB', '<R>', 'CD'],
|
||||
);
|
||||
});
|
||||
|
||||
test('replaceMatch keeps empty surrounding parts produced by processPart', () => {
|
||||
const match = 'XY'.match(/XY/)!;
|
||||
// empty before and after still flow through processPart
|
||||
assert.deepEqual(
|
||||
replaceMatch('XY', match, '<R>', (t) => [t]),
|
||||
['', '<R>', ''],
|
||||
);
|
||||
});
|
||||
|
||||
test('replaceMatch supports non-string content types', () => {
|
||||
const match = 'aXb'.match(/X/)!;
|
||||
const node = { type: 'node' };
|
||||
assert.deepEqual(
|
||||
replaceMatch('aXb', match, node, (t) => [t]),
|
||||
['a', node, 'b'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { emojis } from './emoji';
|
||||
|
||||
// A Map-backed MatrixClient stub supporting get/setAccountData.
|
||||
const createMx = () => {
|
||||
const store = new Map<string, unknown>();
|
||||
const mx = {
|
||||
getAccountData: (type: string): MatrixEvent | undefined => {
|
||||
if (!store.has(type)) return undefined;
|
||||
const content = store.get(type);
|
||||
return { getContent: () => content } as unknown as MatrixEvent;
|
||||
},
|
||||
setAccountData: (type: string, content: unknown) => {
|
||||
store.set(type, content);
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
return { mx: mx as unknown as MatrixClient, store };
|
||||
};
|
||||
|
||||
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
|
||||
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.recent_emoji;
|
||||
|
||||
// Pick two real unicode emojis to drive add->get round trips.
|
||||
const u1 = emojis[0].unicode;
|
||||
const u2 = emojis[1].unicode;
|
||||
|
||||
test('getRecentEmojis returns [] when there is no account data', () => {
|
||||
const { mx } = createMx();
|
||||
assert.deepEqual(getRecentEmojis(mx), []);
|
||||
});
|
||||
|
||||
test('getRecentEmojis returns [] when content is not an array', () => {
|
||||
const { mx, store } = createMx();
|
||||
store.set(AccountDataEvent.ElementRecentEmoji, { recent_emoji: 'nope' });
|
||||
assert.deepEqual(getRecentEmojis(mx), []);
|
||||
});
|
||||
|
||||
test('addRecentEmoji creates an entry with count 1', () => {
|
||||
const { mx, store } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
assert.deepEqual(getStored(store), [[u1, 1]]);
|
||||
});
|
||||
|
||||
test('adding the same emoji again increments its count and keeps it at front', () => {
|
||||
const { mx, store } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
addRecentEmoji(mx, u1);
|
||||
assert.deepEqual(getStored(store), [[u1, 2]]);
|
||||
});
|
||||
|
||||
test('a newly added emoji is promoted to the front', () => {
|
||||
const { mx, store } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
addRecentEmoji(mx, u2);
|
||||
assert.deepEqual(getStored(store), [
|
||||
[u2, 1],
|
||||
[u1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('re-adding an existing emoji promotes it to front and increments', () => {
|
||||
const { mx, store } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
addRecentEmoji(mx, u2);
|
||||
addRecentEmoji(mx, u1);
|
||||
assert.deepEqual(getStored(store), [
|
||||
[u1, 2],
|
||||
[u2, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('list is capped at 100 entries', () => {
|
||||
const { mx, store } = createMx();
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
addRecentEmoji(mx, `:emoji-${i}:`);
|
||||
}
|
||||
const stored = getStored(store)!;
|
||||
assert.equal(stored.length, 100);
|
||||
// most recently added is at the front
|
||||
assert.equal(stored[0][0], ':emoji-119:');
|
||||
});
|
||||
|
||||
test('getRecentEmojis resolves stored unicodes to known emoji objects', () => {
|
||||
const { mx } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
addRecentEmoji(mx, u2);
|
||||
const recent = getRecentEmojis(mx);
|
||||
assert.equal(recent.length, 2);
|
||||
assert.equal(recent[0].unicode, u2);
|
||||
assert.equal(recent[1].unicode, u1);
|
||||
});
|
||||
|
||||
test('getRecentEmojis skips unicodes with no matching emoji definition', () => {
|
||||
const { mx } = createMx();
|
||||
addRecentEmoji(mx, ':not-a-real-emoji:');
|
||||
addRecentEmoji(mx, u1);
|
||||
const recent = getRecentEmojis(mx);
|
||||
assert.equal(recent.length, 1);
|
||||
assert.equal(recent[0].unicode, u1);
|
||||
});
|
||||
|
||||
test('getRecentEmojis respects the limit argument (slice before resolve)', () => {
|
||||
const { mx } = createMx();
|
||||
addRecentEmoji(mx, u1);
|
||||
addRecentEmoji(mx, u2);
|
||||
const recent = getRecentEmojis(mx, 1);
|
||||
assert.equal(recent.length, 1);
|
||||
assert.equal(recent[0].unicode, u2);
|
||||
});
|
||||
Reference in New Issue
Block a user