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:
64
apps/web/src/lib/data/emoji-catalog.test.ts
Normal file
64
apps/web/src/lib/data/emoji-catalog.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/web/src/lib/data/emoji-catalog.ts
Normal file
119
apps/web/src/lib/data/emoji-catalog.ts
Normal 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'
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user