From ff45a0ab4eaaa473da028b06f3e52951d04dc114 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:32:55 -0700 Subject: [PATCH 1/2] fix(v2): an astral first character is one initial, not half of one (TASK-189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initialsFor` picked its letters by UTF-16 unit. A display name starting with an astral character β€” Deseret 𐐀, a CJK extension glyph, an emoji β€” yielded the high surrogate ALONE: not a character, and drawn as a replacement box by fonts that have nothing to render it. The one-word branch cut two characters with `slice(0, 2)`, which also lands between the halves of the second code point ("Aπ”˜" is three units). The fallback branch had the same read. All three now go through `Array.from`. BMP behaviour is unchanged, which is why this never surfaced as mojibake for non-Latin names: the punctuation strip above is already code-point aware (`\p{L}`/`\p{N}`), and only the picking was not. Three new tests, one per class of witness: the three astral reads, a property assertion that no result is a lone surrogate, and the fallback's two-character read. Four mutations, each red on the arm it names (the reds print the real mojibake β€” "\uFFFDB" for 𐐀lpha Beta, "A\uFFFD" for Aπ”˜, one emoji for πŸ˜€πŸ˜€), 10/10 green after each restore. 118 suites / 1048 tests, tsc 0, eslint 0. --- .../src/v2/__tests__/avatarInitials.test.ts | 37 +++++++++++++++++++ frontend/src/v2/utils/avatars.ts | 20 ++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/avatarInitials.test.ts b/frontend/src/v2/__tests__/avatarInitials.test.ts index c36ae7e0f..fcb968ad0 100644 --- a/frontend/src/v2/__tests__/avatarInitials.test.ts +++ b/frontend/src/v2/__tests__/avatarInitials.test.ts @@ -41,6 +41,43 @@ describe('initialsFor', () => { expect(initialsFor('ε₯ΆιΎ™')).toBe('ε₯ΆιΎ™'); }); + test('an astral character is taken whole, never cut in half', () => { + // A JS string indexes UTF-16 units, so a name starting with an astral + // character used to yield the high surrogate ALONE β€” "\ud801", which is not + // a character and renders as a replacement box or nothing at all. Deseret + // capital 𐐀 is U+10400, two units. + expect(initialsFor('𐐀lpha Beta')).toBe('𐐀B'); + expect(initialsFor('Deseret 𐐀team')).toBe('D𐐀'); + // The one-word branch cuts after TWO characters, so here the second code + // point is the one at risk: 'Aπ”˜' is three units, and slice(0, 2) was "A" + // plus half of π”˜. + expect(initialsFor('Aπ”˜')).toBe('Aπ”˜'); + }); + + test('no result is ever a lone surrogate', () => { + // The class, not the three instances above: a lone surrogate is a single + // UTF-16 unit inside the surrogate range, whereas a whole astral character + // iterates as a two-unit string. Names with no letters at all reach the + // fallback branch, so an emoji-only name is the input that pins THAT read. + const loneSurrogates = (s: string) => + Array.from(s).filter((ch) => ch.length === 1 && ch >= '\uD800' && ch <= '\uDFFF'); + const names = [ + '𐐀lpha Beta', 'Deseret 𐐀team', 'Aπ”˜', 'π”˜', 'πŸ˜€πŸ˜€', 'πŸ˜€ Launch', + 'ε₯ΆιΎ™', 'Sprint Review', 'Fable (lead)', '(lead)', 'β€”', '', 'Aβ€”π”˜', + ]; + for (const name of names) { + expect({ name, lone: loneSurrogates(initialsFor(name)) }).toEqual({ name, lone: [] }); + } + }); + + test('a name with nothing strippable left still reads two whole characters', () => { + // The third UTF-16 read was the fallback: a slice(0, 2) of the raw string. + // An emoji-only name has no letter or digit token, so it lands here, and + // the slice used to take one emoji where two were meant. + expect(initialsFor('πŸ˜€πŸ˜€')).toBe('πŸ˜€πŸ˜€'); + expect(initialsFor('πŸ˜€')).toBe('πŸ˜€'); + }); + test('empty and punctuation-only names fall back rather than throwing', () => { expect(initialsFor('')).toBe('?'); expect(initialsFor(null)).toBe('?'); diff --git a/frontend/src/v2/utils/avatars.ts b/frontend/src/v2/utils/avatars.ts index 60d370019..13e49b535 100644 --- a/frontend/src/v2/utils/avatars.ts +++ b/frontend/src/v2/utils/avatars.ts @@ -107,9 +107,23 @@ export const initialsFor = (name: string | undefined | null): string => { .map((part) => part.replace(/[^\p{L}\p{N}]/gu, '')) .filter(Boolean); - if (parts.length === 0) return trimmed.slice(0, 2).toUpperCase() || '?'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + // Every read below is by CODE POINT, not by UTF-16 unit. A JS string indexes + // UTF-16 units, so `parts[0][0]` on a name that starts with an astral + // character β€” Deseret 𐐀, a CJK extension glyph, an emoji β€” returns the high + // surrogate ALONE: not a character, and drawn as a replacement box or a blank + // by fonts that have nothing to render. The same cut lands in a two-character + // slice that falls between the halves of the second code point: "Aπ”˜" is three + // units, so `slice(0, 2)` was "A" plus half of π”˜. Array.from splits on code + // points, so a glyph is either whole or absent. Note the strip above is + // already code-point aware (`\p{L}`/`\p{N}`) β€” the mismatch was in the + // picking, not the matching, which is why it never showed up as mojibake in a + // name that was merely non-Latin. + const firstCodePoints = (part: string, count: number): string => + Array.from(part).slice(0, count).join(''); + + if (parts.length === 0) return firstCodePoints(trimmed, 2).toUpperCase() || '?'; + if (parts.length === 1) return firstCodePoints(parts[0], 2).toUpperCase(); + return `${firstCodePoints(parts[0], 1)}${firstCodePoints(parts[parts.length - 1], 1)}`.toUpperCase(); }; // ── Character tier ────────────────────────────────────────────────────────── From 42b14f977bfae25dc89b030e892070c2ff4c636e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:39:44 -0700 Subject: [PATCH 2/2] test(v2): the lone-surrogate class assertion can see the fallback read (TASK-189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's finding, reproduced before it was accepted. The class assertion could not fire for the fallback branch, and the reason was the name list rather than the assertion: reaching that branch needs a name with no letter or digit token, and splitting a pair there needs the second code point to sit across the cut. `'πŸ˜€πŸ˜€'` does the first and not the second, so with the fallback reverted it returns `'πŸ˜€'` β€” a truncation, zero lone surrogates, a green class assertion over a regression. Measured with the fallback read reverted in place (third column = lone-surrogate count in the result): "πŸ˜€πŸ˜€" "πŸ˜€" 0 ← truncation, invisible to the class test "β€”πŸ˜€" "β€”\ud83d" 1 ← the shape that sees it "Β·πŸ˜€Β·" "Β·\ud83d" 1 (same shape, different punctuation) `'β€”πŸ˜€'` is punctuation-then-astral and the only shape that does both, so it is the element added. Fixed gives `'β€”πŸ˜€'`; reverted gives `'β€”' + '\ud83d'`. The fallback revert now reddens two tests β€” the class assertion and the explicit truncation test β€” which is the pairing that shows they are not redundant. 118 suites / 1048 tests, tsc --noEmit exit 0, eslint exit 0. --- frontend/src/v2/__tests__/avatarInitials.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/__tests__/avatarInitials.test.ts b/frontend/src/v2/__tests__/avatarInitials.test.ts index fcb968ad0..efac7f146 100644 --- a/frontend/src/v2/__tests__/avatarInitials.test.ts +++ b/frontend/src/v2/__tests__/avatarInitials.test.ts @@ -59,10 +59,19 @@ describe('initialsFor', () => { // UTF-16 unit inside the surrogate range, whereas a whole astral character // iterates as a two-unit string. Names with no letters at all reach the // fallback branch, so an emoji-only name is the input that pins THAT read. + // + // 'β€”πŸ˜€' is the shape that makes the fallback read visible to THIS assertion + // rather than only to its own test, and it is the only shape that does both + // of the two things the fallback needs. Reaching the fallback takes a name + // with no letter or digit token at all; splitting a pair there takes the + // second code point to sit across the cut. 'πŸ˜€πŸ˜€' does the first and not the + // second, so with its read reverted it comes back as 'πŸ˜€' β€” a truncation, and + // zero lone surrogates, which is a green class assertion over a regression. + // Measured: fixed gives 'β€”πŸ˜€', reverted gives 'β€”' + '\ud83d'. const loneSurrogates = (s: string) => Array.from(s).filter((ch) => ch.length === 1 && ch >= '\uD800' && ch <= '\uDFFF'); const names = [ - '𐐀lpha Beta', 'Deseret 𐐀team', 'Aπ”˜', 'π”˜', 'πŸ˜€πŸ˜€', 'πŸ˜€ Launch', + '𐐀lpha Beta', 'Deseret 𐐀team', 'Aπ”˜', 'π”˜', 'πŸ˜€πŸ˜€', 'πŸ˜€ Launch', 'β€”πŸ˜€', 'ε₯ΆιΎ™', 'Sprint Review', 'Fable (lead)', '(lead)', 'β€”', '', 'Aβ€”π”˜', ]; for (const name of names) {