Files
livewire-material/tests/Browser/ProgressTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

187 lines
7.7 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 ready(visit('/material/progress', $options));
}
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));
});