Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

136 lines
5.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Brand } from '@open-design/contracts';
import { brandToMemoryEntries, reflowBrandToMemory } from '../src/brands/memory.js';
import { listMemoryEntries, readMemoryEntry, writeMemoryConfig } from '../src/memory.js';
// A realistic two-stack editorial brand (modeled on the Economist brand pack):
// seven roles, serif display + sans body, a documented voice, square-corner
// layout posture, and logo usage notes.
const ECONOMIST: Brand = {
name: 'The Economist',
tagline: 'A severe contest between intelligence and ignorance.',
description: 'A weekly international newspaper.',
sourceUrl: 'https://economist.com/',
logo: {
primary: 'logos/service-google.png',
alternates: ['logos/service-ddg.ico'],
notes:
"White serif 'E' knocked out of an Economist Red square. Never recolor the red field, never outline it.",
},
colors: [
{ role: 'background', hex: '#ffffff', oklch: '', name: 'Paper White', usage: 'page background' },
{ role: 'surface', hex: '#f2f2f2', oklch: '', name: 'London 95', usage: 'panels' },
{ role: 'foreground', hex: '#0d0d0d', oklch: '', name: 'London 5', usage: 'ink' },
{ role: 'muted', hex: '#595959', oklch: '', name: 'London 35', usage: 'captions' },
{ role: 'border', hex: '#d9d9d9', oklch: '', name: 'London 85', usage: 'hairlines' },
{ role: 'accent', hex: '#e3120b', oklch: '', name: 'Economist Red', usage: 'masthead' },
{ role: 'accent-secondary', hex: '#2e45b8', oklch: '', name: 'Chicago 30', usage: 'links' },
],
typography: {
display: { family: 'Milo TE', fallbacks: ['Georgia', 'Times New Roman', 'serif'], weights: [400, 700] },
body: { family: 'Econ Sans', fallbacks: ['Helvetica Neue', 'Arial', 'sans-serif'], weights: [400, 700] },
},
voice: {
adjectives: ['authoritative', 'concise', 'witty'],
tone: 'A single unsigned house voice that argues a position.',
messagingPillars: ['Analysis over breaking news', 'A global outlook'],
vocabulary: { use: ['this newspaper', 'leader', 'briefing'], avoid: ['content', 'hot take', 'synergy'] },
},
imagery: {
style: 'Editorial illustration and photojournalism in service of an argument.',
subjects: ['world leaders', 'markets'],
treatment: 'Bold conceptual illustration.',
avoid: ['generic stock-photo handshakes', 'emoji and clip-art'],
},
layout: {
radius: '0px',
borderWeight: '1px',
spacing: '8px baseline grid',
postureRules: ['Square corners everywhere', 'Hairline rules separate stories'],
},
};
describe('brandToMemoryEntries', () => {
it('derives a coherent visual / voice / rule set with a stable slug', () => {
const entries = brandToMemoryEntries(ECONOMIST);
const byId = new Map(entries.map((e) => [e.id, e]));
// One user visual-direction, one reference voice, four rules.
expect(byId.has('user_brand_the_economist_visual')).toBe(true);
expect(byId.has('reference_brand_the_economist_voice')).toBe(true);
expect(byId.has('rule_brand_the_economist_palette')).toBe(true);
expect(byId.has('rule_brand_the_economist_type')).toBe(true);
expect(byId.has('rule_brand_the_economist_layout')).toBe(true);
expect(byId.has('rule_brand_the_economist_logo')).toBe(true);
const visual = byId.get('user_brand_the_economist_visual')!;
expect(visual.type).toBe('user');
expect(visual.body).toContain('#e3120b');
expect(visual.body).toContain('Milo TE');
// Palette rule reads as an enforceable Assertion/Check the self-verify pass
// can score against, and lists every allowed hex.
const palette = byId.get('rule_brand_the_economist_palette')!;
expect(palette.type).toBe('rule');
expect(palette.body).toMatch(/^Assertion:/);
expect(palette.body).toContain('Check:');
expect(palette.body).toContain('#2e45b8');
const logo = byId.get('rule_brand_the_economist_logo')!;
expect(logo.body).toContain('Never recolor the red field');
});
it('skips facets the brand does not carry', () => {
const bare: Brand = {
...ECONOMIST,
voice: { adjectives: [], tone: '', messagingPillars: [], vocabulary: { use: [], avoid: [] } },
imagery: { style: '', subjects: [], treatment: '', avoid: [] },
logo: { primary: null, alternates: [], notes: '' },
};
const ids = brandToMemoryEntries(bare).map((e) => e.id);
expect(ids).not.toContain('reference_brand_the_economist_voice');
expect(ids).not.toContain('rule_brand_the_economist_logo');
// Visual + palette + type + layout still derive from the colors/type/layout.
expect(ids).toContain('user_brand_the_economist_visual');
expect(ids).toContain('rule_brand_the_economist_palette');
});
});
describe('reflowBrandToMemory', () => {
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(path.join(os.tmpdir(), 'od-brand-memory-'));
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
it('writes brand entries through the memory store and links them in the index', async () => {
const result = await reflowBrandToMemory(dataDir, ECONOMIST);
expect(result.written.length).toBeGreaterThanOrEqual(5);
const stored = await listMemoryEntries(dataDir);
const ids = stored.map((e) => e.id);
expect(ids).toContain('user_brand_the_economist_visual');
expect(ids).toContain('rule_brand_the_economist_palette');
const palette = await readMemoryEntry(dataDir, 'rule_brand_the_economist_palette');
expect(palette?.type).toBe('rule');
});
it('writes nothing when memory is disabled', async () => {
await writeMemoryConfig(dataDir, { enabled: false });
const result = await reflowBrandToMemory(dataDir, ECONOMIST);
expect(result.skipped).toBe('memory-disabled');
expect(result.written).toEqual([]);
expect(await listMemoryEntries(dataDir)).toEqual([]);
});
});