feat(shared): add avatarFor(name) util — deterministic 10-color name-hash (§5)
Refs #855 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
92
frontend/src/lib/shared/avatar.spec.ts
Normal file
92
frontend/src/lib/shared/avatar.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { avatarFor } from './avatar';
|
||||
import { AVATAR_PALETTE } from './avatarPalette';
|
||||
|
||||
// ─── palette ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AVATAR_PALETTE via avatarFor', () => {
|
||||
it('palette has exactly 10 colors', () => {
|
||||
expect(AVATAR_PALETTE).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── avatarFor ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarFor', () => {
|
||||
it('returns a bg color from the 10-color palette', () => {
|
||||
const result = avatarFor('Marcel Raddatz');
|
||||
expect(AVATAR_PALETTE as readonly string[]).toContain(result.bg);
|
||||
});
|
||||
|
||||
it('is deterministic — same name always returns same color', () => {
|
||||
expect(avatarFor('Karl Müller').bg).toBe(avatarFor('Karl Müller').bg);
|
||||
expect(avatarFor('Karl Müller').initials).toBe(avatarFor('Karl Müller').initials);
|
||||
});
|
||||
|
||||
it('returns first + last initial for a two-word name', () => {
|
||||
expect(avatarFor('Anna Raddatz').initials).toBe('AR');
|
||||
});
|
||||
|
||||
it('returns first + last initial for a three-word name (middle name ignored)', () => {
|
||||
expect(avatarFor('Anna Maria Raddatz').initials).toBe('AR');
|
||||
});
|
||||
|
||||
it('returns single initial for a single-word name', () => {
|
||||
expect(avatarFor('Raddatz').initials).toBe('R');
|
||||
});
|
||||
|
||||
it('returns empty initials and palette[0] for an empty string (guard)', () => {
|
||||
const result = avatarFor('');
|
||||
expect(result.initials).toBe('');
|
||||
expect(result.bg).toBe(AVATAR_PALETTE[0]);
|
||||
});
|
||||
|
||||
it('does not throw on whitespace-only name', () => {
|
||||
expect(() => avatarFor(' ')).not.toThrow();
|
||||
const result = avatarFor(' ');
|
||||
expect(result.initials).toBe('');
|
||||
});
|
||||
|
||||
it('initials are uppercased', () => {
|
||||
expect(avatarFor('anna raddatz').initials).toBe('AR');
|
||||
});
|
||||
|
||||
it('all 10 palette entries are reachable across varied names', () => {
|
||||
const names = [
|
||||
'Alpha Beta',
|
||||
'Gamma Delta',
|
||||
'Epsilon Zeta',
|
||||
'Eta Theta',
|
||||
'Iota Kappa',
|
||||
'Lambda Mu',
|
||||
'Nu Xi',
|
||||
'Omicron Pi',
|
||||
'Rho Sigma',
|
||||
'Tau Upsilon',
|
||||
'Phi Chi',
|
||||
'Psi Omega',
|
||||
'Anna Schmidt',
|
||||
'Karl Weber',
|
||||
'Maria Müller',
|
||||
'Heinrich Braun',
|
||||
'Clara Raddatz',
|
||||
'Ernst Wagner',
|
||||
'Helene Fischer',
|
||||
'Otto Klein'
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
for (const n of names) seen.add(avatarFor(n).bg);
|
||||
// We should hit more than 1 color across 20 varied names
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses h * 31 hash (name-keyed, not djb2)', () => {
|
||||
// Verify that the same name produces stable output across calls
|
||||
// (implementation-agnostic determinism test)
|
||||
const name = 'Herbert Cram';
|
||||
const r1 = avatarFor(name);
|
||||
const r2 = avatarFor(name);
|
||||
expect(r1.bg).toBe(r2.bg);
|
||||
expect(r1.initials).toBe(r2.initials);
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/shared/avatar.ts
Normal file
43
frontend/src/lib/shared/avatar.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { AVATAR_PALETTE } from './avatarPalette';
|
||||
|
||||
export type AvatarResult = {
|
||||
bg: string;
|
||||
initials: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic name-hashed avatar util (DESIGN_RULES §5).
|
||||
*
|
||||
* - Hash: `h = (h * 31 + charCode) >>> 0` (unsigned 32-bit, name-keyed)
|
||||
* - Initials: first char of first token + first char of last token, uppercased
|
||||
* - Empty name → safe fallback: empty initials, palette[0]
|
||||
* - Single-token name → one initial
|
||||
*
|
||||
* Lives in `$lib/shared/` so Avatar.svelte does not import from any domain.
|
||||
* Do NOT use this for id-hashing — the deliberate trade-off (stable across
|
||||
* screens, shifts on rename) is documented in issue #855.
|
||||
*/
|
||||
export function avatarFor(name: string): AvatarResult {
|
||||
const trimmed = name.trim();
|
||||
|
||||
// Empty / whitespace-only guard
|
||||
if (!trimmed) {
|
||||
return { bg: AVATAR_PALETTE[0], initials: '' };
|
||||
}
|
||||
|
||||
// Hash: h = (h * 31 + charCode) >>> 0
|
||||
let h = 0;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
h = (h * 31 + trimmed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
|
||||
const bg = AVATAR_PALETTE[h % AVATAR_PALETTE.length];
|
||||
|
||||
// Initials: first + last token initial
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const first = parts[0][0] ?? '';
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
|
||||
const initials = (first + last).toUpperCase();
|
||||
|
||||
return { bg, initials };
|
||||
}
|
||||
Reference in New Issue
Block a user