/** * Tests for the FilamentSwatch component (#1154). * * Covers the three independent inputs the swatch composes (rgba, extraColors, * effectType) and the buildFilamentBackground helper used to paint banners. */ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; import { render } from '../utils'; import { FilamentSwatch } from '../../components/FilamentSwatch'; import { buildFilamentBackground } from '../../components/filamentSwatchHelpers'; describe('FilamentSwatch', () => { it('renders a solid swatch when only rgba is set', () => { render(); const el = screen.getByTestId('filament-swatch'); // Solid swatches are emitted as a 1-stop linear-gradient so the // checkerboard layer below is still visible through alpha. const bg = el.getAttribute('style') ?? ''; expect(bg).toMatch(/linear-gradient/); expect(bg.toLowerCase()).toContain('#ff0000ff'); }); it('falls back to grey when nothing is set', () => { render(); const el = screen.getByTestId('filament-swatch'); expect(el.style.backgroundImage.toLowerCase()).toContain('#808080'); }); it('renders a linear gradient when extraColors has multiple stops', () => { render(); const el = screen.getByTestId('filament-swatch'); const bg = el.style.backgroundImage.toLowerCase(); // Linear (not conic) for non-Multicolor subtype. expect(bg).toMatch(/linear-gradient/); expect(bg).toContain('#ec984c'); expect(bg).toContain('#6cd4bc'); expect(bg).toContain('#a66eb9'); expect(bg).toContain('#d87694'); }); it('uses conic-gradient for Multicolor subtype', () => { render( , ); const el = screen.getByTestId('filament-swatch'); expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/); }); it('also uses conic-gradient when effectType is multicolor (catalog path)', () => { // Catalog entries don't have a `subtype`, so the multicolor effect_type // value also has to trigger conic rendering for parity with the spool path. render(); const el = screen.getByTestId('filament-swatch'); expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/); }); it('layers an effect overlay on top of the colour layer for sparkle', () => { render(); const el = screen.getByTestId('filament-swatch'); // Sparkle overlay is built from radial-gradient layers — confirm at least // one is in the composed background, ahead of the colour layer. expect(el.style.backgroundImage).toMatch(/radial-gradient/); }); it('renders an overlay for silk variant', () => { // Silk gets a soft sheen overlay (added in #1154 follow-up). render(); const el = screen.getByTestId('filament-swatch'); expect(el.style.backgroundImage).toMatch(/linear-gradient/); }); it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => { // No extra_colors set → swatch falls back to the solid colour layer; the // categorical effect value alone does not paint a sheen overlay. render(); const el = screen.getByTestId('filament-swatch'); // No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either — // gradient/dual-color/tri-color are pure labels until extra_colors is set. expect(el.style.backgroundImage).not.toMatch(/radial-gradient/); }); it('ignores unknown effect types instead of throwing', () => { render(); const el = screen.getByTestId('filament-swatch'); expect(el.style.backgroundImage).not.toMatch(/radial-gradient/); }); it('renders a checkerboard underneath so alpha is visible', () => { render(); const el = screen.getByTestId('filament-swatch'); // The component always appends a checkerboard layer last so semi- // transparent rgba values actually look transparent to the user. expect(el.style.backgroundImage).toMatch(/repeating-conic-gradient/); }); it('skips invalid hex tokens in extraColors instead of throwing', () => { render(); const el = screen.getByTestId('filament-swatch'); const bg = el.style.backgroundImage.toLowerCase(); // The two valid stops survive; the garbage token is dropped. expect(bg).toContain('#ff0000'); expect(bg).toContain('#00ff00'); expect(bg).not.toContain('not-hex'); }); it('uses extra_colors for the title fallback when provided', () => { render(); const el = screen.getByTestId('filament-swatch'); // Tooltip should show the comma-joined hex stops, not the (unset) rgba. expect(el.title.toLowerCase()).toContain('#ff0000'); expect(el.title.toLowerCase()).toContain('#00ff00'); }); }); describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => { // Bug: the original #1154 fix produced an identical // ``linear-gradient(135deg, A, B)`` for both Gradient and Dual Color // effects, so a "Dual Color" spool looked indistinguishable from a // "Gradient" one — both rendered as a smooth diagonal blend. Real // dual-colour spools have two visually distinct bars, not a blend. // These tests pin the corrected rendering: a horizontal hard split // for dual-color / tri-color, the original 135° smooth blend for // everything else. it('renders dual-color as a hard horizontal split, not a diagonal blend', () => { const bg = buildFilamentBackground({ extraColors: '7f3696,006ec9', effectType: 'dual-color', effectSize: 'table', }); const lower = bg.backgroundImage.toLowerCase(); // Hard split direction — ``to right`` (or ``90deg``), never ``135deg``. expect(lower).toContain('to right'); expect(lower).not.toContain('135deg'); // Both colour stops present. expect(lower).toContain('#7f3696'); expect(lower).toContain('#006ec9'); // Each colour occupies its own segment via double-position stops, so // the colour change is a hard line rather than a blend region. expect(lower).toMatch(/#7f3696\s+0\.000%\s+50\.000%/); expect(lower).toMatch(/#006ec9\s+50\.000%\s+100\.000%/); }); it('renders tri-color as three equal hard-split bars', () => { const bg = buildFilamentBackground({ extraColors: 'ff0000,00ff00,0000ff', effectType: 'tri-color', effectSize: 'table', }); const lower = bg.backgroundImage.toLowerCase(); expect(lower).toContain('to right'); // Each third gets its own contiguous segment. expect(lower).toMatch(/#ff0000\s+0\.000%\s+33\.333%/); expect(lower).toMatch(/#00ff00\s+33\.333%\s+66\.667%/); expect(lower).toMatch(/#0000ff\s+66\.667%\s+100\.000%/); }); it('keeps the smooth 135° diagonal for the default Gradient effect', () => { const bg = buildFilamentBackground({ extraColors: '7f3696,006ec9', effectType: 'gradient', effectSize: 'table', }); const lower = bg.backgroundImage.toLowerCase(); // Original visual preserved for non-dual / non-tri stops. expect(lower).toContain('135deg'); expect(lower).not.toContain('to right'); // Stops are concatenated without explicit positions — CSS does the // smooth blend across the diagonal. expect(lower).toContain('#7f3696'); expect(lower).toContain('#006ec9'); }); it('regression: dual-color and gradient produce visually distinct backgrounds', () => { // Direct regression guard for the reporter's exact symptom — the two // effects must NOT collapse to the same CSS string. If a future refactor // accidentally drops the dual-color branch, this assertion fires before // anyone has to retest in a browser. const dual = buildFilamentBackground({ extraColors: '7f3696,006ec9', effectType: 'dual-color', effectSize: 'table', }); const grad = buildFilamentBackground({ extraColors: '7f3696,006ec9', effectType: 'gradient', effectSize: 'table', }); expect(dual.backgroundImage).not.toBe(grad.backgroundImage); }); }); describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)', () => { it('renders dense sparkle on card preset (at least 10 dots)', () => { // The original Sparkle pattern was 4 dots — too subtle on a 200×60px // banner. Now we use situation-aware dot counts: more dots for larger presets. // Verify the card preset produces a dense pattern with at least 10 dots. render(); const el = screen.getByTestId('filament-swatch'); const radialCount = (el.style.backgroundImage.match(/radial-gradient/g) ?? []).length; expect(radialCount).toBeGreaterThanOrEqual(10); }); it('uses fixed-pixel checkerboard tile so cell density is independent of swatch size', () => { // Without per-layer background-size, ``cover`` stretched the conic // gradient over the whole element and a card-sized banner only showed // 4 huge cells. Verify the checker layer carries an explicit pixel // tile size. const bg = buildFilamentBackground({ rgba: 'ff0000ff', effectSize: 'table' }); const sizes = bg.backgroundSize.split(',').map((s) => s.trim()); // Last layer is the checker; should be a fixed pixel tile, not 'cover'. expect(sizes[sizes.length - 1]).toMatch(/^\d+px(\s+\d+px)?$/); expect(sizes[sizes.length - 1]).not.toContain('cover'); }); it('limits sparkle dot count per size preset (table/card/bar)', () => { const tableBg = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'table', }); const cardBg = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'card', }); const barBg = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'bar', }); const countRadial = (css: string) => (css.match(/radial-gradient/g) ?? []).length; expect(countRadial(tableBg.backgroundImage)).toBe(5); expect(countRadial(cardBg.backgroundImage)).toBe(40); expect(countRadial(barBg.backgroundImage)).toBe(20); }); it('scales sparkle dot radii by size preset while keeping seeded output deterministic', () => { const tableBg = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'table', }); const barBg = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'bar', }); const tableBgRepeat = buildFilamentBackground({ rgba: 'ff0000ff', effectType: 'sparkle', effectSize: 'table', }); const tableBgOther = buildFilamentBackground({ rgba: '00ff00ff', effectType: 'sparkle', effectSize: 'table', }); // Same seed must produce byte-identical overlay output. expect(tableBg.backgroundImage).toBe(tableBgRepeat.backgroundImage); // Different seeds must produce different overlay output. expect(tableBg.backgroundImage).not.toBe(tableBgOther.backgroundImage); // Radius grows for bar preset, preventing sparse-looking large banners. // Extract the first radius from each CSS string by looking for "0 Xpx, transparent Ypx" const tableR = tableBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/); const barR = barBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/); if (!tableR || !barR) { throw new Error(`Failed to extract radii: tableR=${tableR}, barR=${barR}`); } expect(Number(tableR[1])).toBeLessThan(Number(barR[1])); expect(Number(tableR[2])).toBeLessThan(Number(barR[2])); }); }); describe('buildFilamentBackground', () => { it('emits a CSS-style object with layered images and per-layer sizes', () => { const bg = buildFilamentBackground({ rgba: 'ff0000ff', extraColors: 'aabbcc,ddeeff', effectType: 'matte', effectSize: 'table', }); // Effect overlay → colour layer → checkerboard, in that order. expect(bg.backgroundImage).toMatch(/linear-gradient/); expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/); expect(bg.backgroundImage.toLowerCase()).toContain('#aabbcc'); expect(bg.backgroundImage.toLowerCase()).toContain('#ddeeff'); // Per-layer sizes — three comma-separated values (effect/colour/checker) // in the same order. The checker has a fixed pixel tile so the cell // density doesn't scale with the element (#1154 follow-up). const sizeParts = bg.backgroundSize.split(',').map((s) => s.trim()); expect(sizeParts).toHaveLength(3); expect(sizeParts[2]).toMatch(/\d+px/); }); it('returns a usable solid background when only rgba is provided', () => { const bg = buildFilamentBackground({ rgba: '00ff00ff', effectSize: 'table' }); expect(bg.backgroundImage.toLowerCase()).toContain('#00ff00ff'); expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/); }); });