diff --git a/src/app/plugins/emoji.test.ts b/src/app/plugins/emoji.test.ts new file mode 100644 index 000000000..1261decc3 --- /dev/null +++ b/src/app/plugins/emoji.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { getHexcodeForEmoji, getShortcodeFor, getShortcodesFor } from './emoji'; + +describe('getHexcodeForEmoji', () => { + it('converts a single astral codepoint to an uppercase hexcode', () => { + // 😀 = U+1F600 + assert.equal(getHexcodeForEmoji('😀'), '1F600'); + }); + + it('zero-pads BMP codepoints to at least four hex digits', () => { + // ☺ = U+263A ; # = U+0023 (must pad "23" -> "0023") + assert.equal(getHexcodeForEmoji('☺'), '263A'); + assert.equal(getHexcodeForEmoji('#'), '0023'); + }); + + it('strips the FE0F variation selector by default', () => { + // ❤️ = U+2764 U+FE0F + assert.equal(getHexcodeForEmoji('❤️'), '2764'); + }); + + it('keeps the variation selector when strip is false', () => { + assert.equal(getHexcodeForEmoji('❤️', false), '2764-FE0F'); + }); + + it('strips ZWJ (200D) joiners from a sequence by default', () => { + // 👨‍👩‍👧 = 1F468 200D 1F469 200D 1F467 + assert.equal(getHexcodeForEmoji('👨‍👩‍👧'), '1F468-1F469-1F467'); + }); + + it('keeps ZWJ joiners when strip is false', () => { + assert.equal(getHexcodeForEmoji('👨‍👩‍👧', false), '1F468-200D-1F469-200D-1F467'); + }); + + it('strips the FE0E text-presentation selector too', () => { + // ▶ = U+25B6 ; ▶︎ = U+25B6 U+FE0E (text presentation) + assert.equal(getHexcodeForEmoji('▶︎'), '25B6'); + assert.equal(getHexcodeForEmoji('▶︎', false), '25B6-FE0E'); + }); + + it('handles a keycap sequence (padding + selector strip together)', () => { + // #️⃣ = U+0023 U+FE0F U+20E3 -> "0023" + (FE0F stripped) + "20E3" + assert.equal(getHexcodeForEmoji('#️⃣'), '0023-20E3'); + }); + + it('handles degenerate inputs (empty string, plain ASCII per codepoint)', () => { + assert.equal(getHexcodeForEmoji(''), ''); + assert.equal(getHexcodeForEmoji('ab'), '0061-0062'); + }); +}); + +describe('getShortcodesFor / getShortcodeFor before emoji data is loaded', () => { + // These gracefully degrade to `undefined` until loadEmojiData() has populated + // the shortcode maps — the contract that lets tooltips/aria-labels render + // eagerly without pulling the emojibase runtime into the eager graph. + it('returns undefined for getShortcodesFor', () => { + assert.equal(getShortcodesFor('1F600'), undefined); + }); + + it('returns undefined for getShortcodeFor', () => { + assert.equal(getShortcodeFor('1F600'), undefined); + }); +}); diff --git a/src/app/utils/dom.test.ts b/src/app/utils/dom.test.ts new file mode 100644 index 000000000..2b65e8f8e --- /dev/null +++ b/src/app/utils/dom.test.ts @@ -0,0 +1,158 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + canFitInScrollView, + getThumbnailDimensions, + isInScrollView, + isIntersectingScrollView, + syntaxErrorPosition, + tryDecodeURIComponent, +} from './dom'; + +// The scroll-view helpers only read numeric layout properties off their +// elements, so a plain duck-typed object stands in for an HTMLElement. +type ElLike = { + offsetTop?: number; + scrollTop?: number; + offsetHeight?: number; + clientHeight?: number; +}; +const el = (props: ElLike): HTMLElement => props as unknown as HTMLElement; + +describe('getThumbnailDimensions', () => { + it('leaves dimensions within the 400x300 cap untouched', () => { + assert.deepEqual(getThumbnailDimensions(200, 150), [200, 150]); + assert.deepEqual(getThumbnailDimensions(400, 300), [400, 300]); + assert.deepEqual(getThumbnailDimensions(100, 100), [100, 100]); + }); + + it('scales down by height when taller than 300', () => { + // 200x600 -> width * (300/600) = 100, height clamped to 300 + assert.deepEqual(getThumbnailDimensions(200, 600), [100, 300]); + }); + + it('scales down by width when wider than 400', () => { + // 800x200 -> height * (400/800) = 100, width clamped to 400 + assert.deepEqual(getThumbnailDimensions(800, 200), [400, 100]); + }); + + it('applies the height clamp first, then the width clamp', () => { + // 800x600 -> height clamp: 400x300 (width already at cap, no further change) + assert.deepEqual(getThumbnailDimensions(800, 600), [400, 300]); + // 1200x600 -> height clamp: 600x300 -> width clamp: 400x200 + assert.deepEqual(getThumbnailDimensions(1200, 600), [400, 200]); + }); + + it('floors fractional results', () => { + // 300x700 -> width * (300/700) = 128.57 -> floored to 128 + assert.deepEqual(getThumbnailDimensions(300, 700), [128, 300]); + }); + + it('scales on a just-over-boundary input (strict > comparisons)', () => { + // one over the height cap -> scales; one over the width cap -> scales + assert.deepEqual(getThumbnailDimensions(400, 301), [398, 300]); + assert.deepEqual(getThumbnailDimensions(401, 300), [400, 299]); + }); +}); + +describe('tryDecodeURIComponent', () => { + it('decodes a valid encoded component', () => { + assert.equal(tryDecodeURIComponent('a%20b'), 'a b'); + assert.equal(tryDecodeURIComponent('%C3%A9'), 'é'); + }); + + it('returns the input unchanged when it has no escapes', () => { + assert.equal(tryDecodeURIComponent('hello'), 'hello'); + }); + + it('returns the raw input on a malformed sequence instead of throwing', () => { + assert.equal(tryDecodeURIComponent('%'), '%'); + assert.equal(tryDecodeURIComponent('%E0%A4%A'), '%E0%A4%A'); + }); +}); + +describe('syntaxErrorPosition', () => { + it('extracts the position when the number ends the message (real V8/Node shape)', () => { + // Real JSON.parse errors read "... at position N" with N at end-of-string. + assert.equal( + syntaxErrorPosition(new SyntaxError('Unexpected end of JSON input at position 10')), + 10, + ); + }); + + it('extracts the position when it is followed by more text', () => { + // Newer V8 appends "(line N column M)" after the number. + assert.equal( + syntaxErrorPosition(new SyntaxError('bad token in JSON at position 6 (line 1 column 7)')), + 6, + ); + assert.equal(syntaxErrorPosition(new SyntaxError('bad at position 42 more')), 42); + }); + + it('returns undefined when the message has no position', () => { + assert.equal(syntaxErrorPosition(new SyntaxError('Unexpected end of input')), undefined); + }); +}); + +describe('isIntersectingScrollView', () => { + // Viewport spans 0..100 (offsetTop 0 + scrollTop 0, height 100). + const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 }); + + it('is true for a child fully inside the view', () => { + assert.equal(isIntersectingScrollView(view, el({ offsetTop: 20, clientHeight: 30 })), true); + }); + + it('is true for a child straddling the top edge', () => { + // -10..20 -> bottom (20) is within 0..100 + assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 30 })), true); + }); + + it('is true for a child taller than and spanning the whole view', () => { + // -20..180 -> top above, bottom below + assert.equal(isIntersectingScrollView(view, el({ offsetTop: -20, clientHeight: 200 })), true); + }); + + it('is false for a child entirely above or below the view', () => { + assert.equal(isIntersectingScrollView(view, el({ offsetTop: -50, clientHeight: 20 })), false); + assert.equal(isIntersectingScrollView(view, el({ offsetTop: 200, clientHeight: 20 })), false); + }); + + it('respects the strict pixel boundaries (> vs >=)', () => { + // child bottom sits exactly on scrollTop (0) -> not intersecting (childBottom > scrollTop is strict) + assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 10 })), false); + // child top sits exactly on scrollBottom (100) -> not intersecting (childTop < scrollBottom is strict) + assert.equal(isIntersectingScrollView(view, el({ offsetTop: 100, clientHeight: 20 })), false); + }); + + it('accounts for the view scrollTop offset', () => { + // View 0..100 in layout, scrolled by 100 -> logical window 100..200. + const scrolled = el({ offsetTop: 0, scrollTop: 100, offsetHeight: 100 }); + assert.equal( + isIntersectingScrollView(scrolled, el({ offsetTop: 120, clientHeight: 10 })), + true, + ); + assert.equal( + isIntersectingScrollView(scrolled, el({ offsetTop: 20, clientHeight: 10 })), + false, + ); + }); +}); + +describe('isInScrollView', () => { + const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 }); + + it('is true only when the child is fully within the view', () => { + assert.equal(isInScrollView(view, el({ offsetTop: 10, offsetHeight: 50 })), true); + // straddles the bottom edge -> not fully in + assert.equal(isInScrollView(view, el({ offsetTop: 80, offsetHeight: 50 })), false); + }); +}); + +describe('canFitInScrollView', () => { + it('is true when the child is shorter than the view', () => { + const view = el({ offsetHeight: 100 }); + assert.equal(canFitInScrollView(view, el({ offsetHeight: 60 })), true); + assert.equal(canFitInScrollView(view, el({ offsetHeight: 100 })), false); + assert.equal(canFitInScrollView(view, el({ offsetHeight: 140 })), false); + }); +}); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 40cfc3fcc..b3ca88bf1 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -230,7 +230,11 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => { }; export const syntaxErrorPosition = (error: SyntaxError): number | undefined => { - const match = error.message.match(/position\s(\d+)\s/); + // The number may sit at the very end of the message — real V8/Node JSON + // errors read "... at position 7" with no trailing character — so do NOT + // require whitespace after the digits (that made this return undefined for + // every real error, silently pointing the editors' cursor at position 0). + const match = error.message.match(/position\s(\d+)/); if (!match) return undefined; const posStr = match[1];