From ae6ceef34fe0d01fec4018885045fc04202bd07e Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 19 May 2026 03:11:44 +0200 Subject: [PATCH] feat(fase-19): emoji catalog data module + EC unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded `EMOJI_CATALOG: Record` 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 --- apps/web/src/lib/data/emoji-catalog.test.ts | 64 +++++++++++ apps/web/src/lib/data/emoji-catalog.ts | 119 ++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 apps/web/src/lib/data/emoji-catalog.test.ts create mode 100644 apps/web/src/lib/data/emoji-catalog.ts diff --git a/apps/web/src/lib/data/emoji-catalog.test.ts b/apps/web/src/lib/data/emoji-catalog.test.ts new file mode 100644 index 0000000..f64eefa --- /dev/null +++ b/apps/web/src/lib/data/emoji-catalog.test.ts @@ -0,0 +1,64 @@ +/** + * Fase 19.4.2 — EmojiCatalog data-module unit tests. + * + * The catalog is a hardcoded `Record`. 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(); + 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(); + } + }); +}); diff --git a/apps/web/src/lib/data/emoji-catalog.ts b/apps/web/src/lib/data/emoji-catalog.ts new file mode 100644 index 0000000..061edbb --- /dev/null +++ b/apps/web/src/lib/data/emoji-catalog.ts @@ -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 = { + // ── 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 = { + faces: 'Caras', + food: 'Comida', + animals: 'Animales', + objects: 'Objetos' +};