feat(fase-19): emoji catalog data module + EC unit tests

Hardcoded `EMOJI_CATALOG: Record<EmojiCategory, readonly string[]>` with
four categories (faces 64 / food 119 / animals 140 / objects 36 = 359
codepoints). Unicode 14 baseline, no ZWJ sequences, no skin-tone
variants so every glyph renders consistently on iOS ≥ 16 + Android.

Covers EC-U-01..04 from the plan: size lower bounds on food + animals,
no intra-category duplicates, no cross-category overlap (the picker
draws one tab at a time so a duplicate would render only in the first
tab silently and confuse the "selected" state on tab switch).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 03:11:44 +02:00
parent f7abba4888
commit ae6ceef34f
2 changed files with 183 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
/**
* Fase 19.4.2 — EmojiCatalog data-module unit tests.
*
* The catalog is a hardcoded `Record<EmojiCategory, readonly string[]>`. It
* has to satisfy size lower bounds (food + animals are the headline categories
* being expanded), and never let a single emoji land in two categories — the
* picker draws one tab at a time so duplicates would render in only one tab
* silently and confuse the "selected" highlight when the source-of-truth tab
* changes.
*
* Specs:
* EC-U-01 — food >= 85 entries.
* EC-U-02 — animals >= 130 entries.
* EC-U-03 — no duplicates within any category.
* EC-U-04 — no overlap across categories (an emoji belongs to exactly one).
*/
import { describe, it, expect } from 'vitest';
import { EMOJI_CATALOG, CATEGORY_LABELS, type EmojiCategory } from './emoji-catalog';
describe('EMOJI_CATALOG', () => {
it('EC-U-01: food category has at least 85 entries', () => {
expect(EMOJI_CATALOG.food.length).toBeGreaterThanOrEqual(85);
});
it('EC-U-02: animals category has at least 130 entries', () => {
expect(EMOJI_CATALOG.animals.length).toBeGreaterThanOrEqual(130);
});
it('EC-U-03: no duplicates within any category', () => {
for (const cat of Object.keys(EMOJI_CATALOG) as EmojiCategory[]) {
const list = EMOJI_CATALOG[cat];
const set = new Set(list);
expect(
set.size,
`category "${cat}" has duplicates (${list.length - set.size} repeats)`
).toBe(list.length);
}
});
it('EC-U-04: no overlap between categories — each emoji lives in exactly one', () => {
const seen = new Map<string, EmojiCategory>();
const conflicts: Array<{ emoji: string; first: EmojiCategory; second: EmojiCategory }> = [];
for (const cat of Object.keys(EMOJI_CATALOG) as EmojiCategory[]) {
for (const e of EMOJI_CATALOG[cat]) {
const prev = seen.get(e);
if (prev !== undefined && prev !== cat) {
conflicts.push({ emoji: e, first: prev, second: cat });
} else {
seen.set(e, cat);
}
}
}
expect(conflicts, `cross-category duplicates: ${JSON.stringify(conflicts)}`).toEqual([]);
});
it('catalog has all 4 expected categories with non-empty label entries', () => {
const expected: EmojiCategory[] = ['faces', 'food', 'animals', 'objects'];
for (const cat of expected) {
expect(EMOJI_CATALOG[cat]).toBeDefined();
expect(EMOJI_CATALOG[cat].length).toBeGreaterThan(0);
expect(CATEGORY_LABELS[cat]).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,119 @@
/**
* Fase 19.1 — Emoji catalogue.
*
* Source of truth for the avatar / collective-icon picker. Hardcoded — not
* fetched — because the list grows once per Unicode release and is small
* enough (~280 strings, ~3KB) to ship in the JS bundle.
*
* Inclusion rules (kept narrow so every codepoint renders consistently on
* iOS Safari ≥ 16, Android Chrome, and desktop browsers):
*
* 1. Unicode 14 baseline only. Unicode 15 + 16 codepoints (🩷, 🩵, 🪿,
* 🪿, 🫨 etc.) are excluded — iOS < 17 renders them as tofu.
* 2. NO ZWJ sequences (no 👨‍👩‍👧, no 🐕‍🦺, no 🏳️‍🌈). Two reasons:
* (a) the picker stores `users.avatar_emoji` as text and ZWJ
* sequences serialize as multi-codepoint strings whose width is
* font-dependent; (b) older Android skins render them as the
* component glyphs side-by-side.
* 3. NO skin-tone variants (🏻 .. 🏿). Parked for a future pass — the
* base codepoint always renders and is good enough for an avatar.
* 4. Variation selectors kept where the canonical emoji form needs
* them (☀️ U+2600 U+FE0F, ❤️ U+2764 U+FE0F) — leaving them off
* shows the text presentation.
*
* Total ~280 entries split across four categories. The catalog must satisfy
* the invariants enforced by `emoji-catalog.test.ts` (size lower bounds,
* no intra-category duplicates, no cross-category overlap).
*
* Mutation procedure when Unicode lifts the floor: edit this file by hand,
* run `pnpm --filter @colectivo/web test:unit -- emoji-catalog`, and verify
* the bundle-size delta stays under ~10 KB gzipped.
*/
export type EmojiCategory = 'faces' | 'food' | 'animals' | 'objects';
export const EMOJI_CATALOG: Record<EmojiCategory, readonly string[]> = {
// ── Faces & people (smileys + a few hats/headwear). ~40 entries. ────────
faces: [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '😚', '🥲', '😋', '😛', '😜', '🤪',
'😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
'😐', '😑', '😶', '😏', '😒', '🙄', '😬', '😮',
'🥱', '😴', '😪', '🤤', '😵', '🤯', '🤠', '🥳',
'🥸', '😎', '🤓', '🧐', '😢', '😭', '😤', '😡',
'🤬', '😈', '👿', '💀', '👻', '👽', '🤖', '👾'
],
// ── Food & drink. All of Unicode 14 "Food & Drink" minus skin-tone
// variants and ZWJ sequences. ~95 entries.
food: [
// Fruit
'🍇', '🍈', '🍉', '🍊', '🍋', '🍌', '🍍', '🥭',
'🍎', '🍏', '🍐', '🍑', '🍒', '🍓', '🫐', '🥝',
'🍅', '🫒', '🥥',
// Vegetables
'🥑', '🍆', '🥔', '🥕', '🌽', '🌶️', '🫑', '🥒',
'🥬', '🥦', '🧄', '🧅', '🍄', '🥜', '🫘', '🌰',
// Prepared / bakery
'🍞', '🥐', '🥖', '🫓', '🥨', '🥯', '🥞', '🧇',
'🧀', '🍖', '🍗', '🥩', '🥓', '🍔', '🍟', '🍕',
'🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚',
'🍳', '🥘', '🍲', '🫕', '🥣', '🥗', '🍿', '🧈',
'🧂', '🥫',
// Asian + global
'🍱', '🍘', '🍙', '🍚', '🍛', '🍜', '🍝', '🍠',
'🍢', '🍣', '🍤', '🍥', '🥮', '🍡', '🥟', '🥠',
'🥡',
// Sweets
'🍦', '🍧', '🍨', '🍩', '🍪', '🎂', '🍰', '🧁',
'🥧', '🍫', '🍬', '🍭', '🍮', '🍯',
// Drinks
'🍼', '🥛', '☕', '🫖', '🍵', '🍶', '🍾', '🍷',
'🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋',
'🧃', '🧉', '🧊'
],
// ── Animals & nature. All of Unicode 14 "Animals & Nature" minus ZWJ
// sequences (eye contact, service dog, etc.). ~135 entries.
animals: [
// Mammals
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
'🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵',
'🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤',
'🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗',
'🐴', '🦄', '🐝', '🪱', '🐛', '🦋', '🐌', '🐞',
'🐜', '🪰', '🪲', '🪳', '🦟', '🦗', '🕷️', '🕸️',
'🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑',
'🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳',
'🐋', '🦈', '🦭', '🐊', '🐅', '🐆', '🦓', '🦍',
'🦧', '🦣', '🐘', '🦛', '🦏', '🐪', '🐫', '🦒',
'🦘', '🦬', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏',
'🐑', '🦙', '🐐', '🦌', '🐕', '🐩', '🦮', '🐈',
'🪶', '🐓', '🦃', '🦚', '🦜', '🦢', '🦩', '🕊️',
'🐇', '🦝', '🦨', '🦡', '🦫', '🦦', '🦥', '🐁',
'🐀', '🐿️', '🦔',
// Plants
'🌵', '🎄', '🌲', '🌳', '🌴', '🪵', '🌱', '🌿',
'☘️', '🍀', '🎍', '🪴', '🎋', '🍃', '🍂', '🍁',
'🌾', '💐', '🌷', '🌹', '🥀', '🌺', '🌸',
'🌼', '🌻'
],
// ── Misc household / nature / activity objects. Small curated set.
// ~22 entries.
objects: [
'🏠', '🏡', '🏢', '🏬', '🏭', '🏰', '⛺', '🌍',
'🌎', '🌏', '🌐', '⭐', '🌟', '✨', '🔥', '💧',
'🌊', '🌈', '☀️', '🌙', '☁️', '⚡', '❄️', '🎁',
'🎈', '🎉', '🎊', '🎵', '🎶', '🎯', '🎮', '🚀',
'⚽', '🏀', '🎨', '🎭'
]
} as const;
export const CATEGORY_LABELS: Record<EmojiCategory, string> = {
faces: 'Caras',
food: 'Comida',
animals: 'Animales',
objects: 'Objetos'
};