diff --git a/frontend/src/v2/__tests__/podRecency.test.ts b/frontend/src/v2/__tests__/podRecency.test.ts index b55f17c00..caf20a8d1 100644 --- a/frontend/src/v2/__tests__/podRecency.test.ts +++ b/frontend/src/v2/__tests__/podRecency.test.ts @@ -37,7 +37,54 @@ describe('podRecency', () => { test('podInitials takes two words, or two letters of one', () => { expect(podInitials('Connectors v2')).toBe('CV'); expect(podInitials('Sharpen')).toBe('SH'); - expect(podInitials(' Payments — memory demo ')).toBe('P—'); expect(podInitials('')).toBe('·'); }); + + test('podInitials separates on punctuation and dashes instead of taking them as initials', () => { + expect(podInitials(' Payments — memory demo ')).toBe('PM'); + expect(podInitials('Sharpen — pod model')).toBe('SP'); + expect(podInitials('Team-Commonly')).toBe('TC'); + expect(podInitials('—')).toBe('·'); + }); + + test('podInitials shows one glyph when the picked glyph is CJK', () => { + expect(podInitials('设计评审')).toBe('设'); + expect(podInitials('Growth 团队')).toBe('G'); + }); + + test('podInitials cuts every CJK script, not just Han', () => { + // Han alone carried the predicate until these existed: dropping any ONE of + // the other three scripts from the class left the suite green, so a later + // "simplification" to a single script would have gone unnoticed. + expect(podInitials('ひらがな')).toBe('ひ'); + expect(podInitials('カタカナ')).toBe('カ'); + // Two words on purpose, so first() and the CJK branch are exercised together. + expect(podInitials('한국어 팀')).toBe('한'); + }); + + test('podInitials states its scope: a fullwidth-Latin pick is not CJK', () => { + // The predicate is a script list and JS \p{…} cannot express + // East_Asian_Width, so fullwidth Latin shows both glyphs. Asserted rather + // than left in a comment — a known miss a test pins is a decision, and this + // assertion belongs in its own test so widening the class relocates it + // instead of tripping the CJK test. + expect(podInitials('ABC')).toBe('AB'); + }); + + test('podInitials drops an emoji the way it drops punctuation', () => { + expect(podInitials('🚀 Launch')).toBe('LA'); + }); + + test('podInitials slices by code point, so a surrogate pair is never cut in half', () => { + // Synthetic on purpose: a two-unit slice of 'A𝔘' ends between the halves of + // 𝔘 and returns a replacement character, which no real pod name would show. + expect(podInitials('A𝔘')).toBe('A𝔘'); + }); + + test('podInitials reads the first code point of each of two words', () => { + // Pins the two-word branch's own code-point read. 'A𝔘' above is a single + // token, so it only exercises the one-word slice; a UTF-16 read here would + // cut 𐐀 in half and return a replacement character. + expect(podInitials('𐐀lpha Beta')).toBe('𐐀B'); + }); }); diff --git a/frontend/src/v2/lib/podRecency.ts b/frontend/src/v2/lib/podRecency.ts index 96ef2b9b3..d6136da92 100644 --- a/frontend/src/v2/lib/podRecency.ts +++ b/frontend/src/v2/lib/podRecency.ts @@ -57,11 +57,42 @@ export const relativeTime = (value: string | number | Date | null | undefined, n return `${Math.floor(delta / YEAR)}y`; }; +// The mark is 22px with 11px mono characters, so two Han characters wrap to a +// second line inside the square — that is why a CJK pick is cut to one glyph. +// +// This is a SCRIPT list, so the rule it states is a CJK rule and not a width +// rule. JS `\p{…}` expresses General_Category, Script and binary properties but +// not East_Asian_Width, so `AB` (fullwidth Latin, EAW=Fullwidth) is out of +// scope by decision and shows both glyphs. The 22px sentence above is the +// motivation for the rule, not a contract this predicate can compute. +const CJK_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + // Two-letter mark for a pod without an avatar: initials of the first two words, // or the first two letters of a one-word name. +// +// Tokenising matches `initialsFor`: the same split, the same [^\p{L}\p{N}] +// strip and the same filter, so punctuation and dashes separate words instead +// of becoming initials and "Sharpen — pod model" is SP rather than S—. It does +// not copy initialsFor's parenthetical drop, deliberately: "(v2)" distinguishes +// a pod name even though "(lead)" never distinguishes an agent. An emoji drops +// out the way punctuation does, which is why "🚀 Launch" is LA rather than 🚀L. A +// name whose picked glyphs are CJK shows one glyph — "设计评审" is 设, not the +// two characters that would wrap. Code points rather than UTF-16 units +// throughout, so a surrogate pair is never cut in half. export const podInitials = (name: string): string => { - const words = (name || '').trim().split(/\s+/).filter(Boolean); + const words = String(name || '') + .trim() + .split(/[\s_\-/|]+/) + .map((word) => word.replace(/[^\p{L}\p{N}]/gu, '')) + .filter(Boolean); if (words.length === 0) return '·'; - if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); - return (words[0][0] + words[1][0]).toUpperCase(); + + const first = (word: string): string => Array.from(word)[0] || ''; + const pick = words.length === 1 + ? Array.from(words[0]).slice(0, 2).join('') + : first(words[0]) + first(words[1]); + + const picked = Array.from(pick); + if (picked.some((char) => CJK_SCRIPT.test(char))) return picked[0]; + return pick.toUpperCase(); };