Files
livewire-material/tests/Feature/SchemeCommandTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 3a7aa96ec7 Add a colour spec choice to material:scheme
The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:52:50 +02:00

220 lines
9.3 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
beforeEach(function () {
$this->stylesheet = sys_get_temp_dir().'/material-scheme-'.Str::random(8).'.css';
$this->data = Str::replaceLast('.css', '.json', $this->stylesheet);
});
afterEach(function () {
File::delete([$this->stylesheet, $this->data]);
});
it('writes the scheme as a stylesheet and as data', function () {
$this->artisan('material:scheme', ['seed' => '#4F46E5', '--output' => $this->stylesheet])
->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
expect($scheme)
->seed->toBe('#4f46e5')
->variant->toBe('tonal-spot')
->spec->toBe('2025')
->and(array_keys($scheme['dark']))->toEqual(array_keys($scheme['light']))
->and($scheme['light'])->toHaveKeys(['primary', 'on-primary', 'tertiary-container', 'surface-container-high', 'outline-variant', 'success', 'on-warning-container', 'info-container'])
->and($scheme['light']['surface'])->not->toBe($scheme['dark']['surface'])
->and($scheme['light']['inverse-success'])->toBe($scheme['dark']['success']);
foreach (['light' => "[data-theme='light']", 'dark' => "[data-theme='dark']"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};");
foreach ($scheme[$theme] as $role => $hex) {
expect($block)->toContain("--md-sys-color-{$role}: {$hex};");
}
}
expect($stylesheet)->toContain('php artisan material:scheme "#4f46e5" --variant=tonal-spot');
});
it('generates a different scheme per variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'vibrant', '--output' => $this->stylesheet])
->assertSuccessful();
$vibrant = json_decode(File::get($this->data), true);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertSuccessful();
$tonalSpot = json_decode(File::get($this->data), true);
expect($vibrant['variant'])->toBe('vibrant')
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
});
it('generates with the 2021 colour spec when asked, and records it', function () {
$this->artisan('material:scheme', [
'seed' => '#00bc7d',
'--variant' => 'vibrant',
'--success' => '#00d390',
'--warning' => '#fcb700',
'--info' => '#00bafe',
'--spec' => '2021',
'--output' => $this->stylesheet,
])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['spec'])->toBe('2021')
->and($scheme['dark'])->toMatchArray([
'surface' => '#0b1610',
'primary' => '#00e297',
'on-primary' => '#003822',
'success' => '#2ce19c',
'warning' => '#ffbb16',
'info' => '#80cfff',
])
->and($scheme['light'])->toMatchArray([
'surface' => '#f0fdf2',
'primary' => '#006c46',
'success' => '#006c48',
'warning' => '#7c5800',
'info' => '#00658c',
])
->and(File::get($this->stylesheet))
->toContain('(spec 2021)')
->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n");
});
it('refuses an unknown colour spec', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet])
->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('refuses a seed that is not a colour', function () {
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
->expectsOutputToContain('#rrggbb')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('refuses an unknown variant', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--variant' => 'loud', '--output' => $this->stylesheet])
->expectsOutputToContain('Unknown variant "loud"')
->assertFailed();
});
it('fails without writing anything when node cannot run', function () {
config(['livewire-material.node' => '/nonexistent/node']);
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--output' => $this->stylesheet])
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('generates every configured profile into one stylesheet keyed by data-scheme', function () {
config([
'livewire-material.profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'],
],
'livewire-material.profile' => 'teal',
]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
$stylesheet = File::get($this->stylesheet);
expect($scheme['default'])->toBe('teal')
->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite'])
->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral')
->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary'])
// The top level is the default profile, as a 1.0 reader expects.
->and($scheme['light'])->toBe($scheme['profiles']['teal']['light'])
->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark'])
->and($scheme['seed'])->toBe('#00897b')
->and($stylesheet)->toContain('php artisan material:scheme'."\n");
// The default's plain blocks come first, so a profile's single-attribute selector follows :root.
expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
foreach ($scheme['profiles'] as $name => $profile) {
foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) {
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
expect($block)->toContain("color-scheme: {$theme};")
->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};")
->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};");
}
}
});
it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () {
config([
'livewire-material.profiles' => [
'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'],
'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'],
],
]);
$this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful();
$profiles = json_decode(File::get($this->data), true)['profiles'];
expect($profiles['classic'])->spec->toBe('2021')
->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff'])
->and($profiles['expressive'])->spec->toBe('2025')
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
->and(File::get($this->stylesheet))
->toContain('(spec 2025)')
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/');
});
it('names the profile whose spec is unknown', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": Unknown spec "2019"')
->assertFailed();
});
it('names the profile a generator error belongs to', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "broken"')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('asks for a seed or profiles when it has neither', function () {
config(['livewire-material.profiles' => []]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('livewire-material.profiles')
->assertFailed();
});
it('refuses a profile name that cannot be an attribute value', function () {
config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('"Ocean Blue" is not')
->assertFailed();
});