/**
* 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/);
});
});