Files
Andreas Reinhold / reiniandClaude Sonnet 5 d15312023b Move browser-test probes off Tailwind utility classes
Plan step 38 (last batch), plan step 42's target for tests/: every
Tailwind utility class in a tests/Browser/*.php probe's Blade string
or Livewire component render() (~60 class attributes across 14 files)
becomes an inline style built from --md-sys-color-*/--md-sys-shape-*/
--md-sys-measurement-* tokens or a literal px value for an arbitrary
demo size — PickingTest's clip box, every probe's <body
class="bg-surface">, the grid/stack/row wrapper divs, the carousel
item's coloured filler. No test's assertions, selectors or expected
text change; these are layout containers around the components under
test, not anything a test reads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:34:20 +02:00

188 lines
7.9 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose value only the server changes, to watch an indicator through a morph.
*/
class ProgressMorphProbe extends Component
{
public int $done = 20;
public function advance(): void
{
$this->done = 80;
}
public function render(): string
{
return <<<'BLADE'
<div style="padding: var(--md-sys-measurement-space200);">
<x-progress :value="$done" label="Server" />
<button type="button" wire:click="advance">Advance</button>
</div>
BLADE;
}
}
/**
* A script run against one indicator in the showcase, scrolled into view first: frames are only
* drawn while an indicator is on screen. `el` is its root; the script's value is the result.
* Pest retries a failing assertion, and gives each attempt a second: keep scripts well inside it.
*/
function onProgress(string $label, string $body): string
{
return <<<JS
(async () => {
const el = document.querySelector('#progress [aria-label="{$label}"]')
// Writes go through the page's own realm: Firefox runs this script in a sandbox whose
// assignments do not reach Alpine's reactive proxies.
const set = (value) => window.eval(`Alpine.\$data(document.querySelector('#progress [aria-label="{$label}"]')).progress = \${JSON.stringify(value)}`)
el.scrollIntoView({ block: 'center' })
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const active = () => el.querySelector('path[stroke="currentColor"]')
const reach = () => { const box = active().getBBox(); return (box.x + box.width) / el.offsetWidth }
await pause(150)
{$body}
})()
JS;
}
function progressShowcase(array $options = [])
{
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started.
return visit('/material/progress', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('draws a linear indicator to its value', function () {
progressShowcase()
->assertNoJavaScriptErrors()
->assertScript(onProgress('Linear', <<<'JS'
const box = active().getBBox()
return Math.abs(box.x - 2) < 0.5 && Math.abs(box.x + box.width - el.offsetWidth * 0.4) < 1
&& el.querySelectorAll('svg svg').length === 0
JS));
});
it('draws a circular arc to its value, from 12 o\'clock', function () {
progressShowcase()->assertScript(onProgress('Circular', <<<'JS'
const path = active()
const end = path.getPointAtLength(path.getTotalLength())
const start = path.getPointAtLength(0)
const turn = (point) => (Math.atan2(point.y - el.offsetHeight / 2, point.x - el.offsetWidth / 2) * 180 / Math.PI + 450) % 360 / 360
return Math.min(turn(start), 1 - turn(start)) < 0.005 && Math.abs(turn(end) - 0.65) < 0.005
JS));
});
it('draws a circular wave as far as its value', function () {
progressShowcase()->assertScript(onProgress('Circular wavy', <<<'JS'
const path = active()
const [dash] = path.getAttribute('stroke-dasharray').split(' ').map(Number)
return path.getAttribute('visibility') === null && Math.abs(dash / (Number(path.getAttribute('pathLength')) / 2) - 0.65) < 0.001
JS));
});
it('moves to a new bound value instead of jumping', function () {
progressShowcase()
->assertScript(onProgress('Bound', <<<'JS'
const before = reach()
set(80)
// The first frame that shows movement, not a timer: under load a timer fires late enough
// that the 330ms spring has nearly arrived, and a jump would look the same as a fast move.
// A jump's first moved frame is already at the target; a move's is on the way.
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
let during = reach()
for (let i = 0; i < 60 && during <= before + 0.01; i++) {
await frame()
during = reach()
}
return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79
JS))
->wait(1)
->assertScript(onProgress('Bound', "return Math.abs(reach() - 0.8) < 0.01 && el.getAttribute('aria-valuenow') === '80'"));
});
it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
set(null)
await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-md-value')
const first = active().getAttribute('d')
await pause(200)
const moving = active().getAttribute('d') !== first
set(50)
await pause(100)
return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01
JS));
});
it('follows a value the server changes through a Livewire morph', function () {
Livewire::component('progress-morph-probe', ProgressMorphProbe::class);
Route::middleware('web')->get('/progress-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:progress-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$reach = "(() => { const el = document.querySelector('[aria-label=\"Server\"]'); const box = el.querySelector('path[stroke=\"currentColor\"]').getBBox(); return (box.x + box.width) / el.offsetWidth })()";
$page = visit('/progress-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript("Math.abs({$reach} - 0.2) < 0.01");
$page->click('Advance')
->wait(1.5)
->assertAttribute('[aria-label="Server"]', 'aria-valuenow', '80')
->assertScript("Math.abs({$reach} - 0.8) < 0.01")
->assertScript("document.querySelectorAll('[aria-label=\"Server\"] svg svg').length === 0");
});
it('animates while indeterminate', function () {
progressShowcase()
->assertScript(onProgress('Linear, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(200)
return active().getAttribute('d') !== first
JS))
->assertScript(onProgress('Circular wavy, indeterminate', <<<'JS'
const group = el.querySelector('g')
const first = group.getAttribute('transform') + active().getAttribute('stroke-dasharray')
await pause(200)
return group.getAttribute('transform') + active().getAttribute('stroke-dasharray') !== first
JS));
});
it('holds still under reduced motion', function () {
progressShowcase(['reducedMotion' => 'reduce'])
->assertScript(onProgress('Wavy, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Wavy', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = 90
await pause(50)
return Math.abs(reach() - 0.9) < 0.01
JS));
});