diff --git a/src/app/plugins/custom-emoji/PackImageReader.test.ts b/src/app/plugins/custom-emoji/PackImageReader.test.ts new file mode 100644 index 000000000..3005bd109 --- /dev/null +++ b/src/app/plugins/custom-emoji/PackImageReader.test.ts @@ -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); +}); diff --git a/src/app/plugins/custom-emoji/PackImagesReader.test.ts b/src/app/plugins/custom-emoji/PackImagesReader.test.ts new file mode 100644 index 000000000..6bb8169de --- /dev/null +++ b/src/app/plugins/custom-emoji/PackImagesReader.test.ts @@ -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); +}); diff --git a/src/app/plugins/custom-emoji/PackMetaReader.test.ts b/src/app/plugins/custom-emoji/PackMetaReader.test.ts new file mode 100644 index 000000000..ec8192179 --- /dev/null +++ b/src/app/plugins/custom-emoji/PackMetaReader.test.ts @@ -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); +}); diff --git a/src/app/plugins/custom-emoji/utils.test.ts b/src/app/plugins/custom-emoji/utils.test.ts new file mode 100644 index 000000000..b9cbb8689 --- /dev/null +++ b/src/app/plugins/custom-emoji/utils.test.ts @@ -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([]), []); +}); diff --git a/src/app/plugins/markdown/block/parser.test.ts b/src/app/plugins/markdown/block/parser.test.ts new file mode 100644 index 000000000..6a43d260d --- /dev/null +++ b/src/app/plugins/markdown/block/parser.test.ts @@ -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), '

Heading

'); + assert.equal(parseBlockMD('### Three', parseInlineMD), '

Three

'); +}); + +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), + '

b

', + ); +}); + +test('heading without a parseInline function keeps the raw text', () => { + assert.equal(parseBlockMD('# Heading', undefined), '

Heading

'); +}); + +test('fenced code block without info string', () => { + assert.equal( + parseBlockMD('```\ncode\n```', parseInlineMD), + '
code\n
', + ); +}); + +test('fenced code block with a language', () => { + assert.equal( + parseBlockMD('```js\ncode\n```', parseInlineMD), + '
code\n
', + ); +}); + +test('fenced code block with a filename adds language and data-label', () => { + assert.equal( + parseBlockMD('```example.json\ncode\n```', parseInlineMD), + '
code\n
', + ); +}); + +test('blockquote single line', () => { + assert.equal( + parseBlockMD('> quote', parseInlineMD), + '
quote
', + ); +}); + +test('blockquote multiple lines', () => { + assert.equal( + parseBlockMD('> a\n> b', parseInlineMD), + '
a
b
', + ); +}); + +test('unordered list', () => { + assert.equal(parseBlockMD('* item', parseInlineMD), ''); +}); + +test('ordered list', () => { + assert.equal( + parseBlockMD('1. item', parseInlineMD), + '
  1. item

', + ); +}); + +test('list with multiple items', () => { + assert.equal( + parseBlockMD('* a\n* b', parseInlineMD), + '', + ); +}); + +test('nested list opens a child list', () => { + assert.equal( + parseBlockMD('* a\n * b', parseInlineMD), + '', + ); +}); + +test('inline markdown inside list items is parsed', () => { + assert.equal( + parseBlockMD('1. **b**', parseInlineMD), + '
  1. b

', + ); +}); + +test('newlines are preserved as
', () => { + assert.equal(parseBlockMD('line1\nline2', parseInlineMD), 'line1
line2'); +}); + +test('empty lines are preserved as
', () => { + assert.equal(parseBlockMD('a\n\nb', parseInlineMD), 'a

b'); +}); + +test('escaped block sequence is unescaped and not treated as a block', () => { + assert.equal(parseBlockMD('\\# not heading', parseInlineMD), '# not heading'); +}); diff --git a/src/app/plugins/markdown/inline/parser.test.ts b/src/app/plugins/markdown/inline/parser.test.ts new file mode 100644 index 000000000..f0de1fbbb --- /dev/null +++ b/src/app/plugins/markdown/inline/parser.test.ts @@ -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**'), 'bold'); +}); + +test('italic with asterisk', () => { + assert.equal(parseInlineMD('*italic*'), 'italic'); +}); + +test('italic with underscore', () => { + assert.equal(parseInlineMD('_italic_'), 'italic'); +}); + +test('underline', () => { + assert.equal(parseInlineMD('__under__'), 'under'); +}); + +test('strikethrough', () => { + assert.equal(parseInlineMD('~~strike~~'), 'strike'); +}); + +test('inline code', () => { + assert.equal(parseInlineMD('`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**`'), '**bold**'); +}); + +test('spoiler', () => { + assert.equal(parseInlineMD('||secret||'), 'secret'); +}); + +test('link', () => { + assert.equal( + parseInlineMD('[alt](https://example.com)'), + 'alt', + ); +}); + +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***'), + 'bold italic', + ); +}); + +test('nesting: bold inside link alt text', () => { + assert.equal( + parseInlineMD('[**b**](https://e.com)'), + 'b', + ); +}); + +test('nesting: bold inside spoiler', () => { + assert.equal( + parseInlineMD('||**b**||'), + 'b', + ); +}); + +test('adjacent tokens of different types are both parsed', () => { + assert.equal(parseInlineMD('**a**_b_'), 'ab'); +}); + +test('text surrounding a token is preserved', () => { + assert.equal(parseInlineMD('pre **mid** post'), 'pre mid post'); +}); + +test('two separate tokens are parsed in their text order', () => { + assert.equal(parseInlineMD('*a* **b**'), 'a b'); + assert.equal(parseInlineMD('__a__ _b_'), 'a b'); +}); + +test('code takes precedence and is resolved before other inline rules', () => { + assert.equal(parseInlineMD('`a` *b*'), 'a b'); +}); + +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*'); +}); diff --git a/src/app/plugins/markdown/inline/runner.test.ts b/src/app/plugins/markdown/inline/runner.test.ts new file mode 100644 index 000000000..e0c693b74 --- /dev/null +++ b/src/app/plugins/markdown/inline/runner.test.ts @@ -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 .... +const makeRule = (token: string, tag: string): InlineMDRule => ({ + match: (text) => text.match(new RegExp(token)), + html: (parse, match) => `<${tag}>${parse(match[0])}`, +}); + +test('runInlineRule applies a matching rule', () => { + assert.equal(runInlineRule('**b**', BoldRule, parseInlineMD), 'b'); +}); + +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 mid 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 b', + ); +}); + +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'); + assert.equal(runInlineRules('a', [a2Rule, aRule], parseInlineMD), 'a'); +}); + +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 b', + ); +}); diff --git a/src/app/plugins/markdown/internal/utils.test.ts b/src/app/plugins/markdown/internal/utils.test.ts new file mode 100644 index 000000000..38cbd3268 --- /dev/null +++ b/src/app/plugins/markdown/internal/utils.test.ts @@ -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, '', (t) => [t]), + ['ab', '', 'cd'], + ); +}); + +test('replaceMatch applies processPart to the surrounding text', () => { + const match = 'abXYcd'.match(/XY/)!; + assert.deepEqual( + replaceMatch('abXYcd', match, '', (t) => [t.toUpperCase()]), + ['AB', '', '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, '', (t) => [t]), + ['', '', ''], + ); +}); + +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'], + ); +}); diff --git a/src/app/plugins/recent-emoji.test.ts b/src/app/plugins/recent-emoji.test.ts new file mode 100644 index 000000000..c23f99a6b --- /dev/null +++ b/src/app/plugins/recent-emoji.test.ts @@ -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(); + 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): 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); +});