From 72a4f477d55e54b95736aa685dd226d74e559888 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 20:25:18 +0200 Subject: [PATCH] Add the containment group's stylesheet test harness tests/Feature/Components/ContainmentStylesheetsTest.php is the containment twin of InputStylesheetsTest.php and ActionStylesheetsTest.php: same shape, import and ViewClasses checks, keyed on the "Containment" block of components.css. Its dataset starts empty and grows by one name per component commit in this stream (plan step 36). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../Components/ContainmentStylesheetsTest.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/Feature/Components/ContainmentStylesheetsTest.php diff --git a/tests/Feature/Components/ContainmentStylesheetsTest.php b/tests/Feature/Components/ContainmentStylesheetsTest.php new file mode 100644 index 00000000..f3621925 --- /dev/null +++ b/tests/Feature/Components/ContainmentStylesheetsTest.php @@ -0,0 +1,108 @@ +mediaQueries() as $query) { + preg_match_all('/\(([^()]*)\)/', $query, $features); + + foreach ($features[1] as $feature) { + preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER); + + foreach ($lengths as [, $number, $unit]) { + expect($unit)->toBe('px', "{$label}: {$query}"); + expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint"); + } + } + } +} + +/** + * Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints + * are px, at one of M3's four. + */ +function assertContainmentTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void +{ + $source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css); + + expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i') + ->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/') + ->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/'); + + // A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 px` in a colour role. + $colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)'; + + preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER); + + foreach ($shadows as [, $value]) { + expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role"); + } + + assertContainmentBreakpointsInPx($css, $label); +} + +it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { + $css = ComponentStylesheet::read($name); + + expect($css->css)->toStartWith('/*') + ->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;') + ->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/') + ->and($css->blocks())->each->toBe('@layer material.components') + ->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/'); + + foreach ($css->imports() as $import) { + expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist"); + } +})->with('containment components'); + +it('imports the stylesheet of every component its view renders', function (string $name) { + preg_match_all('/unique() + // A component still written for Tailwind has no stylesheet of its own to import yet; its + // old stylesheet, if it has one, lives in tailwind.css without the layer statement. + ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components')) + ->map(fn (string $tag): string => "./{$tag}.css") + ->values() + ->all(); + + expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]); +})->with('containment components'); + +it('writes no class list into the view but the interaction and text classes', function (string $name) { + expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]); +})->with('containment components'); + +it('takes its values from the tokens and its breakpoints in px', function (string $name) { + assertContainmentTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css"); +})->with('containment components'); + +it('is imported from the containment block of components.css', function (string $name) { + $components = File::get(__DIR__.'/../../../resources/css/components.css'); + $block = substr($components, (int) strpos($components, '/* Containment */')); + $block = substr($block, 0, (int) strpos($block, '/* Navigation */')); + + expect($block)->toContain("@import './components/{$name}.css';"); +})->with('containment components');