diff --git a/frontend/src/v2/__tests__/avatarInitials.test.ts b/frontend/src/v2/__tests__/avatarInitials.test.ts index c36ae7e0f..efac7f146 100644 --- a/frontend/src/v2/__tests__/avatarInitials.test.ts +++ b/frontend/src/v2/__tests__/avatarInitials.test.ts @@ -41,6 +41,52 @@ 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. + // + // '—😀' 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', '—😀', + '奶龙', '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 ──────────────────────────────────────────────────────────