FilamentSwatch.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /**
  2. * Tests for the FilamentSwatch component (#1154).
  3. *
  4. * Covers the three independent inputs the swatch composes (rgba, extraColors,
  5. * effectType) and the buildFilamentBackground helper used to paint banners.
  6. */
  7. import { describe, it, expect } from 'vitest';
  8. import { screen } from '@testing-library/react';
  9. import { render } from '../utils';
  10. import { FilamentSwatch } from '../../components/FilamentSwatch';
  11. import { buildFilamentBackground } from '../../components/filamentSwatchHelpers';
  12. describe('FilamentSwatch', () => {
  13. it('renders a solid swatch when only rgba is set', () => {
  14. render(<FilamentSwatch rgba="ff0000ff" effectSize='table' />);
  15. const el = screen.getByTestId('filament-swatch');
  16. // Solid swatches are emitted as a 1-stop linear-gradient so the
  17. // checkerboard layer below is still visible through alpha.
  18. const bg = el.getAttribute('style') ?? '';
  19. expect(bg).toMatch(/linear-gradient/);
  20. expect(bg.toLowerCase()).toContain('#ff0000ff');
  21. });
  22. it('falls back to grey when nothing is set', () => {
  23. render(<FilamentSwatch effectSize='table' />);
  24. const el = screen.getByTestId('filament-swatch');
  25. expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
  26. });
  27. it('renders a linear gradient when extraColors has multiple stops', () => {
  28. render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" effectSize='table' />);
  29. const el = screen.getByTestId('filament-swatch');
  30. const bg = el.style.backgroundImage.toLowerCase();
  31. // Linear (not conic) for non-Multicolor subtype.
  32. expect(bg).toMatch(/linear-gradient/);
  33. expect(bg).toContain('#ec984c');
  34. expect(bg).toContain('#6cd4bc');
  35. expect(bg).toContain('#a66eb9');
  36. expect(bg).toContain('#d87694');
  37. });
  38. it('uses conic-gradient for Multicolor subtype', () => {
  39. render(
  40. <FilamentSwatch
  41. rgba="ff0000ff"
  42. extraColors="ec984c,6cd4bc,a66eb9"
  43. subtype="Multicolor"
  44. effectSize='table'
  45. />,
  46. );
  47. const el = screen.getByTestId('filament-swatch');
  48. expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
  49. });
  50. it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
  51. // Catalog entries don't have a `subtype`, so the multicolor effect_type
  52. // value also has to trigger conic rendering for parity with the spool path.
  53. render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" effectSize='table' />);
  54. const el = screen.getByTestId('filament-swatch');
  55. expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
  56. });
  57. it('layers an effect overlay on top of the colour layer for sparkle', () => {
  58. render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize='table' />);
  59. const el = screen.getByTestId('filament-swatch');
  60. // Sparkle overlay is built from radial-gradient layers — confirm at least
  61. // one is in the composed background, ahead of the colour layer.
  62. expect(el.style.backgroundImage).toMatch(/radial-gradient/);
  63. });
  64. it('renders an overlay for silk variant', () => {
  65. // Silk gets a soft sheen overlay (added in #1154 follow-up).
  66. render(<FilamentSwatch rgba="ff0000ff" effectType="silk" effectSize='table' />);
  67. const el = screen.getByTestId('filament-swatch');
  68. expect(el.style.backgroundImage).toMatch(/linear-gradient/);
  69. });
  70. it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
  71. // No extra_colors set → swatch falls back to the solid colour layer; the
  72. // categorical effect value alone does not paint a sheen overlay.
  73. render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" effectSize='table' />);
  74. const el = screen.getByTestId('filament-swatch');
  75. // No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
  76. // gradient/dual-color/tri-color are pure labels until extra_colors is set.
  77. expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
  78. });
  79. it('ignores unknown effect types instead of throwing', () => {
  80. render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" effectSize='table' />);
  81. const el = screen.getByTestId('filament-swatch');
  82. expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
  83. });
  84. it('renders a checkerboard underneath so alpha is visible', () => {
  85. render(<FilamentSwatch rgba="ff000080" effectSize='table' />);
  86. const el = screen.getByTestId('filament-swatch');
  87. // The component always appends a checkerboard layer last so semi-
  88. // transparent rgba values actually look transparent to the user.
  89. expect(el.style.backgroundImage).toMatch(/repeating-conic-gradient/);
  90. });
  91. it('skips invalid hex tokens in extraColors instead of throwing', () => {
  92. render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" effectSize='table' />);
  93. const el = screen.getByTestId('filament-swatch');
  94. const bg = el.style.backgroundImage.toLowerCase();
  95. // The two valid stops survive; the garbage token is dropped.
  96. expect(bg).toContain('#ff0000');
  97. expect(bg).toContain('#00ff00');
  98. expect(bg).not.toContain('not-hex');
  99. });
  100. it('uses extra_colors for the title fallback when provided', () => {
  101. render(<FilamentSwatch extraColors="ff0000,00ff00" effectSize='table' />);
  102. const el = screen.getByTestId('filament-swatch');
  103. // Tooltip should show the comma-joined hex stops, not the (unset) rgba.
  104. expect(el.title.toLowerCase()).toContain('#ff0000');
  105. expect(el.title.toLowerCase()).toContain('#00ff00');
  106. });
  107. });
  108. describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
  109. // Bug: the original #1154 fix produced an identical
  110. // ``linear-gradient(135deg, A, B)`` for both Gradient and Dual Color
  111. // effects, so a "Dual Color" spool looked indistinguishable from a
  112. // "Gradient" one — both rendered as a smooth diagonal blend. Real
  113. // dual-colour spools have two visually distinct bars, not a blend.
  114. // These tests pin the corrected rendering: a horizontal hard split
  115. // for dual-color / tri-color, the original 135° smooth blend for
  116. // everything else.
  117. it('renders dual-color as a hard horizontal split, not a diagonal blend', () => {
  118. const bg = buildFilamentBackground({
  119. extraColors: '7f3696,006ec9',
  120. effectType: 'dual-color',
  121. effectSize: 'table',
  122. });
  123. const lower = bg.backgroundImage.toLowerCase();
  124. // Hard split direction — ``to right`` (or ``90deg``), never ``135deg``.
  125. expect(lower).toContain('to right');
  126. expect(lower).not.toContain('135deg');
  127. // Both colour stops present.
  128. expect(lower).toContain('#7f3696');
  129. expect(lower).toContain('#006ec9');
  130. // Each colour occupies its own segment via double-position stops, so
  131. // the colour change is a hard line rather than a blend region.
  132. expect(lower).toMatch(/#7f3696\s+0\.000%\s+50\.000%/);
  133. expect(lower).toMatch(/#006ec9\s+50\.000%\s+100\.000%/);
  134. });
  135. it('renders tri-color as three equal hard-split bars', () => {
  136. const bg = buildFilamentBackground({
  137. extraColors: 'ff0000,00ff00,0000ff',
  138. effectType: 'tri-color',
  139. effectSize: 'table',
  140. });
  141. const lower = bg.backgroundImage.toLowerCase();
  142. expect(lower).toContain('to right');
  143. // Each third gets its own contiguous segment.
  144. expect(lower).toMatch(/#ff0000\s+0\.000%\s+33\.333%/);
  145. expect(lower).toMatch(/#00ff00\s+33\.333%\s+66\.667%/);
  146. expect(lower).toMatch(/#0000ff\s+66\.667%\s+100\.000%/);
  147. });
  148. it('keeps the smooth 135° diagonal for the default Gradient effect', () => {
  149. const bg = buildFilamentBackground({
  150. extraColors: '7f3696,006ec9',
  151. effectType: 'gradient',
  152. effectSize: 'table',
  153. });
  154. const lower = bg.backgroundImage.toLowerCase();
  155. // Original visual preserved for non-dual / non-tri stops.
  156. expect(lower).toContain('135deg');
  157. expect(lower).not.toContain('to right');
  158. // Stops are concatenated without explicit positions — CSS does the
  159. // smooth blend across the diagonal.
  160. expect(lower).toContain('#7f3696');
  161. expect(lower).toContain('#006ec9');
  162. });
  163. it('regression: dual-color and gradient produce visually distinct backgrounds', () => {
  164. // Direct regression guard for the reporter's exact symptom — the two
  165. // effects must NOT collapse to the same CSS string. If a future refactor
  166. // accidentally drops the dual-color branch, this assertion fires before
  167. // anyone has to retest in a browser.
  168. const dual = buildFilamentBackground({
  169. extraColors: '7f3696,006ec9',
  170. effectType: 'dual-color',
  171. effectSize: 'table',
  172. });
  173. const grad = buildFilamentBackground({
  174. extraColors: '7f3696,006ec9',
  175. effectType: 'gradient',
  176. effectSize: 'table',
  177. });
  178. expect(dual.backgroundImage).not.toBe(grad.backgroundImage);
  179. });
  180. });
  181. describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)', () => {
  182. it('renders dense sparkle on card preset (at least 10 dots)', () => {
  183. // The original Sparkle pattern was 4 dots — too subtle on a 200×60px
  184. // banner. Now we use situation-aware dot counts: more dots for larger presets.
  185. // Verify the card preset produces a dense pattern with at least 10 dots.
  186. render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize="card" />);
  187. const el = screen.getByTestId('filament-swatch');
  188. const radialCount = (el.style.backgroundImage.match(/radial-gradient/g) ?? []).length;
  189. expect(radialCount).toBeGreaterThanOrEqual(10);
  190. });
  191. it('uses fixed-pixel checkerboard tile so cell density is independent of swatch size', () => {
  192. // Without per-layer background-size, ``cover`` stretched the conic
  193. // gradient over the whole element and a card-sized banner only showed
  194. // 4 huge cells. Verify the checker layer carries an explicit pixel
  195. // tile size.
  196. const bg = buildFilamentBackground({ rgba: 'ff0000ff', effectSize: 'table' });
  197. const sizes = bg.backgroundSize.split(',').map((s) => s.trim());
  198. // Last layer is the checker; should be a fixed pixel tile, not 'cover'.
  199. expect(sizes[sizes.length - 1]).toMatch(/^\d+px(\s+\d+px)?$/);
  200. expect(sizes[sizes.length - 1]).not.toContain('cover');
  201. });
  202. it('limits sparkle dot count per size preset (table/card/bar)', () => {
  203. const tableBg = buildFilamentBackground({
  204. rgba: 'ff0000ff',
  205. effectType: 'sparkle',
  206. effectSize: 'table',
  207. });
  208. const cardBg = buildFilamentBackground({
  209. rgba: 'ff0000ff',
  210. effectType: 'sparkle',
  211. effectSize: 'card',
  212. });
  213. const barBg = buildFilamentBackground({
  214. rgba: 'ff0000ff',
  215. effectType: 'sparkle',
  216. effectSize: 'bar',
  217. });
  218. const countRadial = (css: string) => (css.match(/radial-gradient/g) ?? []).length;
  219. expect(countRadial(tableBg.backgroundImage)).toBe(5);
  220. expect(countRadial(cardBg.backgroundImage)).toBe(40);
  221. expect(countRadial(barBg.backgroundImage)).toBe(20);
  222. });
  223. it('scales sparkle dot radii by size preset while keeping seeded output deterministic', () => {
  224. const tableBg = buildFilamentBackground({
  225. rgba: 'ff0000ff',
  226. effectType: 'sparkle',
  227. effectSize: 'table',
  228. });
  229. const barBg = buildFilamentBackground({
  230. rgba: 'ff0000ff',
  231. effectType: 'sparkle',
  232. effectSize: 'bar',
  233. });
  234. const tableBgRepeat = buildFilamentBackground({
  235. rgba: 'ff0000ff',
  236. effectType: 'sparkle',
  237. effectSize: 'table',
  238. });
  239. const tableBgOther = buildFilamentBackground({
  240. rgba: '00ff00ff',
  241. effectType: 'sparkle',
  242. effectSize: 'table',
  243. });
  244. // Same seed must produce byte-identical overlay output.
  245. expect(tableBg.backgroundImage).toBe(tableBgRepeat.backgroundImage);
  246. // Different seeds must produce different overlay output.
  247. expect(tableBg.backgroundImage).not.toBe(tableBgOther.backgroundImage);
  248. // Radius grows for bar preset, preventing sparse-looking large banners.
  249. // Extract the first radius from each CSS string by looking for "0 Xpx, transparent Ypx"
  250. const tableR = tableBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
  251. const barR = barBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
  252. if (!tableR || !barR) {
  253. throw new Error(`Failed to extract radii: tableR=${tableR}, barR=${barR}`);
  254. }
  255. expect(Number(tableR[1])).toBeLessThan(Number(barR[1]));
  256. expect(Number(tableR[2])).toBeLessThan(Number(barR[2]));
  257. });
  258. });
  259. describe('buildFilamentBackground', () => {
  260. it('emits a CSS-style object with layered images and per-layer sizes', () => {
  261. const bg = buildFilamentBackground({
  262. rgba: 'ff0000ff',
  263. extraColors: 'aabbcc,ddeeff',
  264. effectType: 'matte',
  265. effectSize: 'table',
  266. });
  267. // Effect overlay → colour layer → checkerboard, in that order.
  268. expect(bg.backgroundImage).toMatch(/linear-gradient/);
  269. expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/);
  270. expect(bg.backgroundImage.toLowerCase()).toContain('#aabbcc');
  271. expect(bg.backgroundImage.toLowerCase()).toContain('#ddeeff');
  272. // Per-layer sizes — three comma-separated values (effect/colour/checker)
  273. // in the same order. The checker has a fixed pixel tile so the cell
  274. // density doesn't scale with the element (#1154 follow-up).
  275. const sizeParts = bg.backgroundSize.split(',').map((s) => s.trim());
  276. expect(sizeParts).toHaveLength(3);
  277. expect(sizeParts[2]).toMatch(/\d+px/);
  278. });
  279. it('returns a usable solid background when only rgba is provided', () => {
  280. const bg = buildFilamentBackground({ rgba: '00ff00ff', effectSize: 'table' });
  281. expect(bg.backgroundImage.toLowerCase()).toContain('#00ff00ff');
  282. expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/);
  283. });
  284. });