Files
2026-07-13 12:25:08 +08:00

812 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generates the cross-language parity fixtures consumed by every port's test
// suite. Re-run via `npm run fixtures:parity` whenever the JS implementation
// legitimately changes; commit the diff and bring the other ports back in
// sync.
//
// The generator deliberately uses the JS implementation as the reference.
// The other ports are then expected to match these byte-for-byte.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import {
Avatar,
Color,
OptionsDescriptor,
Style,
} from '../../../src/js/core/lib/index.js';
import { Prng } from '../../../src/js/core/lib/Prng.js';
import { Fnv1a } from '../../../src/js/core/lib/Prng/Fnv1a.js';
import { Mulberry32 } from '../../../src/js/core/lib/Prng/Mulberry32.js';
import { Number } from '../../../src/js/core/lib/Utils/Number.js';
import { Initials } from '../../../src/js/core/lib/Utils/Initials.js';
const definitionsDir = join(
import.meta.dirname,
'..',
'..',
'..',
'..',
'styles',
'dist',
);
const STYLE_NAMES = ['initials', 'thumbs', 'glass', 'notionists', 'shape-grid'];
// JSON.stringify leaves U+2028/U+2029 (line/paragraph separators) and BMP
// private-use code points literal; editors flag those as unusual line
// terminators or invisible glyphs. Rewrite them as \uXXXX so the committed
// fixtures hold no such characters. Built from char codes so this source file
// stays clean too.
function escapeInvisible(json) {
let out = '';
for (const ch of json) {
const cp = ch.codePointAt(0);
if (cp === 0x2028 || cp === 0x2029 || (cp >= 0xe000 && cp <= 0xf8ff)) {
out += String.fromCharCode(0x5c) + 'u' + cp.toString(16).padStart(4, '0');
} else {
out += ch;
}
}
return out;
}
function writeJson(relPath, data) {
const filePath = join(import.meta.dirname, relPath);
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, escapeInvisible(JSON.stringify(data, null, 2)) + '\n');
console.log(' wrote', relPath);
}
// ---------------------------------------------------------------------------
// Vendor style definitions
// ---------------------------------------------------------------------------
console.log('Copying style definitions…');
const styles = {};
for (const name of STYLE_NAMES) {
const raw = JSON.parse(
readFileSync(join(definitionsDir, `${name}.min.json`), 'utf8'),
);
styles[name] = raw;
writeJson(join('styles', `${name}.json`), raw);
}
// ---------------------------------------------------------------------------
// Fnv1a fixtures
// ---------------------------------------------------------------------------
console.log('Generating fnv1a.json…');
const fnv1aInputs = [
'',
'a',
'b',
'c',
'hello',
'test',
'foobar',
'123',
'dicebear',
'test:flip',
'test:scale',
'some-long-seed:optionName',
'é',
'日本語',
'🎲',
'a'.repeat(256),
];
writeJson(
'fnv1a.json',
fnv1aInputs.map((input) => ({
input,
hash: Fnv1a.hash(input),
hex: Fnv1a.hex(input),
})),
);
// ---------------------------------------------------------------------------
// Mulberry32 fixtures
// ---------------------------------------------------------------------------
console.log('Generating mulberry32.json…');
const mulberrySeeds = [0, 1, 2, 42, 100, 0x811c9dc5, 0xffffffff];
writeJson(
'mulberry32.json',
mulberrySeeds.map((seed) => {
const m = new Mulberry32(seed);
const sequence = [];
for (let i = 0; i < 5; i++) {
const float = m.nextFloat();
sequence.push({ float, state: m.state() });
}
return { seed, sequence };
}),
);
// ---------------------------------------------------------------------------
// Prng fixtures (key-based methods)
// ---------------------------------------------------------------------------
console.log('Generating prng.json…');
const prngFixtures = {
getValue: [],
pick: [],
weightedPick: [],
bool: [],
float: [],
integer: [],
shuffle: [],
};
const getValueCases = [
{ seed: 'test', key: 'flip' },
{ seed: 'test', key: 'scale' },
{ seed: 'test', key: 'rotate' },
{ seed: 'test', key: 'borderRadius' },
{ seed: 'test', key: 'fontWeight' },
{ seed: 'hello', key: 'flip' },
{ seed: 'hello', key: 'eyesVariant' },
{ seed: 'dicebear', key: 'colorFill' },
{ seed: '日本語', key: 'unicode-key' },
{ seed: '', key: 'empty-seed' },
];
for (const c of getValueCases) {
prngFixtures.getValue.push({
...c,
result: new Prng(c.seed).getValue(c.key),
});
}
// Supplementary-plane test characters, built via code point / code unit so the
// source holds no literal control characters. U+1F600's UTF-16 lead surrogate
// (D83D) sorts BEFORE U+E000 by code unit but AFTER it by code point — so these
// pin that every port sorts by UTF-16 code units, not scalar values.
const EMOJI = String.fromCodePoint(0x1f600);
const PUA = String.fromCharCode(0xe000);
const pickCases = [
{ seed: 'test', key: 'a', items: ['a', 'b', 'c', 'd', 'e'] },
{ seed: 'test', key: 'b', items: ['a', 'b', 'c', 'd', 'e'] },
{ seed: 'test', key: 'c', items: ['a', 'b', 'c', 'd', 'e'] },
{ seed: 'hello', key: 'eyes', items: ['blue', 'green', 'brown', 'hazel'] },
{ seed: 'dicebear', key: 'variant', items: ['variant1', 'variant2', 'variant3'] },
{ seed: 'test', key: 'single', items: ['only'] },
// pre-shuffled order to verify that sorting normalizes input
{ seed: 'test', key: 'a', items: ['e', 'd', 'c', 'b', 'a'] },
// duplicates must be collapsed — same result as the unique set
{ seed: 'test', key: 'a', items: ['a', 'a', 'b', 'b', 'c', 'c', 'd', 'd', 'e', 'e'] },
{ seed: 'test', key: 'single', items: ['only', 'only', 'only'] },
// A two-element astral/BMP pair distinguishes the sort whichever index hits.
{ seed: 'test', key: 'a', items: [EMOJI, PUA] },
{ seed: 'hello', key: 'k', items: ['z', PUA, EMOJI, 'a'] },
];
for (const c of pickCases) {
prngFixtures.pick.push({
...c,
result: new Prng(c.seed).pick(c.key, c.items),
});
}
const weightedPickCases = [
{ seed: 'test', key: 'k', weights: { a: 1, b: 4, c: 2 } },
{ seed: 'test', key: 'l', weights: { a: 1, b: 4, c: 2 } },
{ seed: 'hello', key: 'k', weights: { a: 1, b: 4, c: 2 } },
{ seed: 'test', key: 'k', weights: { heavy: 100, light: 1 } },
{ seed: 'test', key: 'k', weights: { rare: 0, common: 1 } },
// insertion-order independence: same expected result as the first entry above
{ seed: 'test', key: 'k', weights: { c: 2, a: 1, b: 4 } },
{ seed: 'test', key: 'k', weights: { only: 1 } },
// fractional weights in non-sorted insertion order — locks in that every port
// sums in the same order (sorted), since float addition is non-associative
{ seed: 'test', key: 'k', weights: { c: 0.1, a: 0.2, b: 0.3 } },
// Astral-plane keys: UTF-16 vs code-point sort order (see EMOJI/PUA above).
{ seed: 'test', key: 'k', weights: { [EMOJI]: 1, [PUA]: 2, a: 3 } },
];
for (const c of weightedPickCases) {
prngFixtures.weightedPick.push({
...c,
result: new Prng(c.seed).weightedPick(c.key, c.weights),
});
}
const boolCases = [
{ seed: 'test', key: 'k', likelihood: 0 },
{ seed: 'test', key: 'k', likelihood: 25 },
{ seed: 'test', key: 'k', likelihood: 50 },
{ seed: 'test', key: 'k', likelihood: 75 },
{ seed: 'test', key: 'k', likelihood: 100 },
{ seed: 'hello', key: 'flip', likelihood: 50 },
{ seed: 'dicebear', key: 'flip', likelihood: 50 },
];
for (const c of boolCases) {
prngFixtures.bool.push({
...c,
result: new Prng(c.seed).bool(c.key, c.likelihood),
});
}
const floatCases = [
{ seed: 'test', key: 'k', range: { min: 0, max: 1 } },
{ seed: 'test', key: 'k', range: { min: -10, max: 10 } },
{ seed: 'test', key: 'k', range: { min: 10, max: 20 } },
{ seed: 'test', key: 'k', range: { min: 20, max: 10 } }, // reversed
{ seed: 'test', key: 'k', range: { min: 0, max: 100, step: 5 } }, // stepped
{ seed: 'test', key: 'k', range: { min: 3, max: 23, step: 5 } }, // stepped, min not on step grid
{ seed: 'parity-step', key: 'k', range: { min: -10, max: 10, step: 2.5 } }, // stepped, float step
{ seed: 'test', key: 'k', range: { min: 10, max: 20, step: 0 } }, // step 0 → continuous
{ seed: 'hello', key: 'scale', range: { min: 0.5, max: 1.5 } },
{ seed: 'test', key: 'k', range: { min: 42, max: 42 } }, // fixed
// Negative value landing exactly on a .5 boundary after *10000
// (value = -0.40625 → -4062.5). Pins the rounding mode: the reference rounds
// half toward +Infinity (-0.4062), not away from zero (-0.4063).
{ seed: '549449', key: 'colorAngle', range: { min: -1, max: 1 } },
];
for (const c of floatCases) {
prngFixtures.float.push({
...c,
result: new Prng(c.seed).float(c.key, c.range),
});
}
const integerCases = [
{ seed: 'test', key: 'k', range: { min: 1, max: 6 } },
{ seed: 'test', key: 'k', range: { min: 0, max: 100 } },
{ seed: 'test', key: 'k', range: { min: -50, max: 50 } },
{ seed: 'test', key: 'k', range: { min: 10, max: 1 } }, // reversed
{ seed: 'test', key: 'k', range: { min: 3, max: 10, step: 7 } }, // step ignored
{ seed: 'test', key: 'k', range: { min: 5, max: 5 } }, // min == max
{ seed: 'hello', key: 'fontWeight', range: { min: 100, max: 900 } },
];
for (const c of integerCases) {
prngFixtures.integer.push({
...c,
result: new Prng(c.seed).integer(c.key, c.range),
});
}
const shuffleCases = [
{ seed: 'test', key: 'k', items: [] },
{ seed: 'test', key: 'k', items: ['only'] },
{ seed: 'test', key: 'k', items: ['a', 'b', 'c'] },
{ seed: 'test', key: 'k', items: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] },
{ seed: 'hello', key: 'k', items: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] },
// pre-shuffled order to verify that sorting normalizes input
{ seed: 'test', key: 'k', items: ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'] },
// duplicates collapse — output length matches unique-set length
{ seed: 'test', key: 'k', items: ['a', 'a', 'b', 'b', 'c', 'c'] },
// Astral-plane items: UTF-16 vs code-point sort order (see EMOJI/PUA above).
{ seed: 'test', key: 'k', items: ['z', PUA, EMOJI, 'a'] },
];
for (const c of shuffleCases) {
prngFixtures.shuffle.push({
...c,
result: new Prng(c.seed).shuffle(c.key, c.items),
});
}
writeJson('prng.json', prngFixtures);
// ---------------------------------------------------------------------------
// Number formatting fixtures
// ---------------------------------------------------------------------------
//
// The renderer stringifies every numeric SVG attribute via `Number.format`,
// which rounds to at most 5 decimal places. PHP's native float cast and
// Python's `repr` would diverge from JS for small/large magnitudes, so each
// port reimplements it (`DiceBear\Utils\Number::format`,
// `dicebear.utils.Number.format`) and must reproduce it byte-for-byte. These pin
// that contract — covering integers, plain decimals, values that round at the
// 5th decimal, and tiny values that round down to 0.
console.log('Generating numbers.json…');
const numberInputs = [
0, 1, -1, 100, -50, 4096, 1000000,
0.5, 1.5, -1.5, 2.5, 0.1, 0.3, 12.34, 123.456, -148.722, 9999.9999,
0.00001, 0.00005, 0.0001, 0.000005, 0.0000005, 0.0000123456,
4.567872, 0.123456, 1.999995, 1.999994, 0.123455, 0.123445,
5e-7, 1e-7, 1.5e-6, 0.0009999999999999998, 4.9999999999999996e-6,
-4.567872, -0.000005, -0.0001,
];
writeJson(
'numbers.json',
numberInputs.map((input) => ({ input, output: Number.format(input) })),
);
// ---------------------------------------------------------------------------
// Initials fixtures
// ---------------------------------------------------------------------------
//
// `Initials.fromSeed` derives display initials for the `initials` style. The
// `@`-stripping, quote-stripping, and `\p{L}` word matching are easy to get
// subtly wrong per language (e.g. a regex that strips raw bytes instead of code
// points would corrupt multibyte letters like ô/ü). These cases pin the
// contract across ASCII, accented, quote-prefixed, email, CJK, and emoji seeds.
console.log('Generating initials.json…');
const initialsSeeds = [
'AB CD',
'café',
'über',
'côté',
'grün rot',
"l'eau",
'd´or',
'ʼtest',
'user@example.com',
'a',
'',
'日本語',
'🎲 spiel',
'Œuvre',
'rock-paper',
// The whole @ suffix is stripped, including line terminators (dotall): these
// seeds verify a CR / U+2028 / U+2029 right after `@` is removed, not kept.
'a@b' + String.fromCharCode(0x0d) + 'cd',
'a@b' + String.fromCharCode(0x2028) + 'cd',
'a@b' + String.fromCharCode(0x2029) + 'cd',
// Full Unicode case mapping (not simple 1:1): ß→SS, the fi ligature→FI, and
// Greek iota-subscript forms expand to two letters. Languages whose default
// uppercasing is simple-mapping only (Go strings.ToUpper, Dart VM
// toUpperCase) need a full-mapping implementation to pass these.
'ß test',
'fish bar',
'ᾀeolic text',
];
writeJson(
'initials.json',
initialsSeeds.map((seed) => ({ seed, result: Initials.fromSeed(seed) })),
);
// ---------------------------------------------------------------------------
// Color fixtures
// ---------------------------------------------------------------------------
//
// The color helpers feed both the resolver (contrast sorting picks colors, so
// any drift changes rendered avatars) and public API consumers. `luminance`
// involves float math (sRGB linearization with `** 2.4`), where rounding could
// silently diverge between languages; the fixtures pin exact doubles.
console.log('Generating colors.json…');
// Covers 3-, 4-, 6- and 8-digit forms, with/without `#`, and mixed case.
const hexInputs = [
'000',
'#000',
'abc',
'#AbC',
'f0f0',
'#F0f0',
'ff0000',
'#FF0000',
'ff000080',
'#ff000080',
'#1a2b3c',
];
const luminanceInputs = [
'#000000',
'#ffffff',
'#ff0000',
'#00ff00',
'#0000ff',
'#808080',
'#0a5b83',
'#69d2e7',
'#f88c49',
'#abc',
'#1a2b3c',
// Around the sRGB linearization threshold (s <= 0.04045 → channel <= ~10.3):
// 0x0a takes the linear branch, 0x0b the power branch.
'#0a0a0a',
'#0b0b0b',
];
const sortByContrastCases = [
{
candidates: ['#000000', '#ffffff', '#ff0000', '#0000ff'],
refColor: '#ffffff',
},
{
candidates: ['#69d2e7', '#0a5b83', '#f88c49', '#e0e4cc'],
refColor: '#000000',
},
// Equal ratios (same color in different notations) must keep input order —
// pins that every port sorts stably.
{ candidates: ['#ff0000', 'ff0000', '#FF0000', '#f00'], refColor: '#00ff00' },
{ candidates: [], refColor: '#000000' },
{ candidates: ['#123456'], refColor: '#123456' },
];
const filterNotEqualToCases = [
{ candidates: ['#ff0000', '#00ff00', '#0000ff'], excluded: ['ff0000'] },
// The alpha channel is stripped for comparison.
{ candidates: ['#ff000080', '#00ff00'], excluded: ['#ff0000'] },
// Short forms normalize before comparison.
{ candidates: ['#abc', '#def'], excluded: ['#aabbcc'] },
// Filtering would empty the list → falls back to the original candidates.
{ candidates: ['#ff0000', 'ff0000'], excluded: ['#f00'] },
{ candidates: ['#ff0000'], excluded: [] },
];
writeJson('colors.json', {
toHex: hexInputs.map((input) => ({ input, result: Color.toHex(input) })),
toRgbHex: hexInputs.map((input) => ({
input,
result: Color.toRgbHex(input),
})),
parseHex: hexInputs.map((input) => ({ input, result: Color.parseHex(input) })),
luminance: luminanceInputs.map((input) => ({
input,
result: Color.luminance(input),
})),
sortByContrast: sortByContrastCases.map((c) => ({
...c,
result: Color.sortByContrast(c.candidates, c.refColor),
})),
filterNotEqualTo: filterNotEqualToCases.map((c) => ({
...c,
result: Color.filterNotEqualTo(c.candidates, c.excluded),
})),
});
// ---------------------------------------------------------------------------
// Validation fixtures
// ---------------------------------------------------------------------------
//
// Pins which inputs every port must accept or reject. Only the accept/reject
// outcome is shared — error messages are language-specific. The circular
// color reference cases additionally pin the reported resolution chain.
console.log('Generating validation.json…');
const minimalStyle = { canvas: { width: 100, height: 100, elements: [] } };
const styleValidationCases = [
{ id: 'minimal', definition: minimalStyle },
{
id: 'with-component-and-color',
definition: {
canvas: { width: 100, height: 100, elements: [] },
components: {
shape: {
width: 100,
height: 100,
variants: { a: { elements: [] }, b: { elements: [] } },
},
},
colors: { fill: { values: ['#000000'] } },
},
},
{ id: 'empty', definition: {} },
{ id: 'missing-canvas', definition: { components: {} } },
{ id: 'canvas-not-object', definition: { canvas: 'nope' } },
{ id: 'canvas-missing-size', definition: { canvas: { elements: [] } } },
{
id: 'alias-to-unknown-component',
definition: {
canvas: { width: 100, height: 100, elements: [] },
components: { a: { extends: 'missing' } },
},
},
{
id: 'unknown-root-key',
definition: { canvas: { width: 100, height: 100, elements: [] }, unexpected: true },
},
];
const optionsValidationCases = [
{ id: 'empty', options: {} },
{ id: 'seed-string', options: { seed: 'x' } },
{ id: 'seed-number', options: { seed: 123 } },
{ id: 'size-min', options: { size: 1 } },
{ id: 'size-zero', options: { size: 0 } },
{ id: 'size-negative', options: { size: -5 } },
{ id: 'size-float', options: { size: 12.5 } },
{ id: 'size-huge', options: { size: 2000000 } },
{ id: 'flip-valid', options: { flip: 'horizontal' } },
{ id: 'flip-invalid', options: { flip: 'diagonal' } },
{ id: 'rotate-in-range', options: { rotate: 360 } },
{ id: 'rotate-out-of-range', options: { rotate: 361 } },
{ id: 'scale-negative', options: { scale: -1 } },
{ id: 'title-number', options: { title: 123 } },
{ id: 'id-randomization-string', options: { idRandomization: 'yes' } },
{ id: 'background-color-string', options: { backgroundColor: 'ff0000' } },
{ id: 'background-color-list', options: { backgroundColor: ['ff0000'] } },
{ id: 'background-color-invalid-hex', options: { backgroundColor: ['zzz'] } },
{ id: 'unknown-option', options: { unknownOption: 1 } },
];
// A style whose canvas uses color `a`, so resolving it walks the (circular)
// `contrastTo` chain.
function circularStyle(colors) {
return {
canvas: {
width: 100,
height: 100,
elements: [
{
type: 'element',
name: 'rect',
attributes: { fill: { type: 'color', name: 'a' } },
},
],
},
colors,
};
}
const circularColorCases = [
{
id: 'self-reference',
style: circularStyle({ a: { values: ['#000000'], contrastTo: 'a' } }),
},
{
id: 'two-color-cycle',
style: circularStyle({
a: { values: ['#000000'], contrastTo: 'b' },
b: { values: ['#ffffff'], contrastTo: 'a' },
}),
},
{
id: 'three-color-cycle',
style: circularStyle({
a: { values: ['#000000'], contrastTo: 'b' },
b: { values: ['#ffffff'], contrastTo: 'c' },
c: { values: ['#ff0000'], contrastTo: 'a' },
}),
},
];
const validationFixtures = {
styles: styleValidationCases.map((c) => {
let valid = true;
try {
new Style(c.definition);
} catch {
valid = false;
}
return { ...c, valid };
}),
// Validated against the `minimal` style above.
options: optionsValidationCases.map((c) => {
let valid = true;
try {
new Avatar(minimalStyle, c.options);
} catch {
valid = false;
}
return { ...c, valid };
}),
circularColors: circularColorCases.map((c) => {
try {
new Avatar(c.style, { seed: 'x' });
} catch (e) {
if (e.name !== 'CircularColorReferenceError') {
throw new Error(
`${c.id}: expected CircularColorReferenceError, got ${e.name}`,
{ cause: e },
);
}
return { ...c, options: { seed: 'x' }, chain: e.chain };
}
throw new Error(`${c.id}: expected a circular color reference error`);
}),
};
writeJson('validation.json', validationFixtures);
// ---------------------------------------------------------------------------
// Avatar fixtures
// ---------------------------------------------------------------------------
console.log('Generating avatar fixtures…');
const DATA_URI_CASE_IDS = new Set(['plain-seed', 'title-escaping']);
function avatarCases(extra) {
return [
{
id: 'plain-seed',
options: { seed: 'parity-1' },
},
{
id: 'different-seed',
options: { seed: 'parity-2' },
},
{
id: 'size-scale-rotate',
options: { seed: 'parity-1', size: 256, scale: 2, rotate: 45 },
},
{
id: 'translate-border-flip',
options: {
seed: 'parity-1',
translateX: 10,
translateY: -5,
borderRadius: 50,
flip: 'horizontal',
},
},
{
id: 'background-solid',
options: {
seed: 'parity-1',
backgroundColor: ['ff0000'],
backgroundColorFill: ['solid'],
},
},
{
id: 'background-linear',
options: {
seed: 'parity-1',
backgroundColor: ['ff0000', '00ff00'],
backgroundColorFill: ['linear'],
backgroundColorAngle: [45, 45],
},
},
{
id: 'background-radial',
options: {
seed: 'parity-1',
backgroundColor: ['ff0000', '00ff00'],
backgroundColorFill: ['radial'],
},
},
// `size` and `title` together pin the resolution order of the resolved
// options snapshot (size is recorded before title).
{
id: 'title-size',
options: { seed: 'parity-1', size: 128, title: 'Parity Avatar' },
},
// XML-escaping of the <title> element: ampersand, angle brackets, both
// quote styles and a non-BMP code point.
{
id: 'title-escaping',
options: {
seed: 'parity-2',
title: 'Tom & Jerry\'s <"Avatar"> ' + EMOJI,
},
},
...extra,
];
}
const avatarFixtures = {
initials: avatarCases([
{
id: 'font-family-weight',
options: {
seed: 'Florian Körner',
fontFamily: ['Helvetica', 'Arial'],
fontWeight: 700,
},
},
{
id: 'variant-double',
options: { seed: 'AB CD', initialsVariant: ['double'] },
},
{
id: 'variant-single',
options: { seed: 'AB CD', initialsVariant: ['single'] },
},
]),
thumbs: avatarCases([
{
id: 'eyes-mouth-variant',
options: {
seed: 'parity-1',
eyesVariant: ['variant1W10'],
mouthVariant: ['variant1'],
},
},
{
id: 'shape-color-override',
options: {
seed: 'parity-1',
shapeColor: ['ffaa00'],
eyesColor: ['000000'],
mouthColor: ['ff0000'],
},
},
]),
glass: avatarCases([
{
id: 'shape-variants',
options: {
seed: 'parity-1',
shape1Variant: ['a'],
shape2Variant: ['g'],
},
},
{
id: 'shape1-probability-zero',
options: { seed: 'parity-1', shape1Probability: 0 },
},
]),
notionists: avatarCases([
{
id: 'hair-eyes-variant',
options: {
seed: 'parity-1',
hairVariant: ['variant01'],
eyesVariant: ['variant01'],
},
},
{
id: 'gesture-variant',
options: {
seed: 'parity-1',
gestureVariant: ['waveLongArms'],
},
},
]),
'shape-grid': avatarCases([
{
id: 'rows-variant-pinned',
options: { seed: 'parity-1', rowsVariant: ['rows04'] },
},
{
id: 'shape-variant-pinned',
options: { seed: 'parity-1', shapeVariant: ['square'] },
},
{
id: 'shape-probability-zero',
options: { seed: 'parity-1', shapeProbability: 0 },
},
]),
};
for (const [styleName, cases] of Object.entries(avatarFixtures)) {
const styleData = styles[styleName];
const out = cases.map((c) => {
const avatar = new Avatar(styleData, c.options);
const json = avatar.toJSON();
// JSON-round-trip the resolved options so the fixture matches what
// any consumer would see after JSON.stringify — this drops undefined
// values like `size` and `title` when they were not provided.
const resolvedOptions = JSON.parse(JSON.stringify(json.options));
const result = { id: c.id, options: c.options, svg: json.svg, resolvedOptions };
// Pins the percent-encoding contract (JS `encodeURIComponent`: everything
// except `A-Za-z0-9-_.!~*'()` is escaped). Two cases per style suffice —
// the encoder is byte-level, so `plain-seed` (typical SVG markup) plus
// `title-escaping` (escaped XML + multi-byte UTF-8) cover the alphabet
// without doubling every fixture entry.
if (DATA_URI_CASE_IDS.has(c.id)) {
result.dataUri = avatar.toDataUri();
}
return result;
});
writeJson(join('avatars', `${styleName}.json`), out);
}
// ---------------------------------------------------------------------------
// OptionsDescriptor fixtures
// ---------------------------------------------------------------------------
//
// One descriptor per parity style. Pins the field map (types, ranges, sorted
// variant lists, per-color fields) that tooling builds form controls from.
console.log('Generating descriptor fixtures…');
for (const [styleName, styleData] of Object.entries(styles)) {
const descriptor = new OptionsDescriptor(new Style(styleData)).toJSON();
writeJson(join('descriptors', `${styleName}.json`), descriptor);
}
console.log('Done.');