Files
amare/tests/Browser/MotionTest.php
Manoel Freitas 42b282c1f2 feat: adicionar cadência editorial à home (#23)
* feat: adicionar cadência editorial à home

* fix: reconcile pr23 ci with current main

* test: atualizar baselines visuais da home para cadência editorial
2026-08-07 21:40:35 -03:00

350 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\PortfolioCase;
use App\Models\Service;
use App\Models\SiteSetting;
use App\Models\Testimonial;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
SiteSetting::instance()->update([
'hero_title' => 'Celebrações com propósito',
'default_og_image_path' => 'media/hero.jpg',
'default_og_image_alt' => 'Capa editorial',
]);
Service::factory()->published()->featured()->create([
'title' => 'Casamentos',
'summary' => 'Assessoria completa',
]);
});
it('keeps every home motion target in final visible state when reduced motion is preferred', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
$state = $page->script(<<<'JS'
() => {
const hero = document.querySelector('[data-motion="page-open"]');
const title = document.querySelector('#hero-heading');
const index = document.querySelector('[data-chapter-index]');
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
const titleStyle = title ? getComputedStyle(title) : null;
const finalReveals = [...document.querySelectorAll('[data-reveal]')]
.every((node) => node.classList.contains('is-revealed'));
return {
heroActive: hero?.classList.contains('is-active') ?? false,
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
hasIndex: Boolean(index),
hasActiveChapter: Boolean(active),
finalReveals,
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
};
}
JS);
expect($state['heroActive'])->toBeTrue();
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
expect($state['hasIndex'])->toBeTrue();
expect($state['hasActiveChapter'])->toBeTrue();
expect($state['finalReveals'])->toBeTrue();
expect($state['enhancementEnabled'])->toBeFalse();
});
it('activates the home opening with 500ms beats and capped 90ms stagger', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->script(<<<'JS'
() => new Promise((resolve) => {
const wait = () => {
const hero = document.querySelector('[data-motion="page-open"]');
if (hero?.classList.contains('is-active')) {
resolve(true);
return;
}
requestAnimationFrame(wait);
};
wait();
setTimeout(() => resolve(false), 2000);
})
JS);
$state = $page->script(<<<'JS'
() => {
const hero = document.querySelector('[data-motion="page-open"]');
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
.map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration));
const methodItems = [...document.querySelectorAll('[data-chapter="method"] [data-reveal]')];
return {
active: hero?.classList.contains('is-active') ?? false,
beatDurations,
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')),
};
}
JS);
expect($state['active'])->toBeTrue();
expect($state['beatDurations'])->each->toBe(0.5);
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']);
});
it('reveals a section once and keeps it final after returning in the scroll', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$before = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS);
$page->script(<<<'JS'
() => {
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' });
return true;
}
JS);
$page->script(<<<'JS'
() => new Promise((resolve) => {
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]');
const wait = () => {
if (target?.classList.contains('is-revealed')) {
resolve(true);
return;
}
requestAnimationFrame(wait);
};
wait();
})
JS);
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
$after = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS);
expect($before)->toBeFalse();
expect($after)->toBeTrue();
});
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void {
Testimonial::factory()->published()->create([
'quote' => 'Primeiro relato.',
'sort_order' => 10,
]);
Testimonial::factory()->published()->create([
'quote' => 'Segundo relato.',
'sort_order' => 20,
]);
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$readDistances = <<<'JS'
() => [...document.querySelectorAll('[data-chapter="testimonials"] blockquote')]
.map((item) => ({
direction: item.dataset.revealFrom,
distance: getComputedStyle(item).getPropertyValue('--motion-translate-x').trim(),
}))
JS;
$page->resize(1440, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$desktop = $page->script($readDistances);
$page->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$mobile = $page->script($readDistances);
expect($desktop)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 24px)'],
['direction' => 'right', 'distance' => '24px'],
]);
expect($mobile)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 16px)'],
['direction' => 'right', 'distance' => '16px'],
]);
});
it('keeps content and navigation usable without javascript', function (): void {
$this->visit('/', [
'javaScriptEnabled' => false,
])
->assertVisible('#hero-heading')
->assertVisible('[data-testid="home-primary-cta"]')
->click('[data-testid="home-primary-cta"]')
->assertPathIs('/contato');
});
it('keeps targets final when intersection observer is unavailable', function (): void {
$page = $this->visit('/');
$rawPage = $page->page();
$url = $rawPage->url();
$rawPage->context()->addInitScript('window.IntersectionObserver = undefined;');
$rawPage->goto($url);
$state = $page->script(<<<'JS'
() => ({
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
pagesFinal: [...document.querySelectorAll('[data-motion="page-open"]')]
.every((node) => node.classList.contains('is-active')),
revealsFinal: [...document.querySelectorAll('[data-reveal]')]
.every((node) => node.classList.contains('is-revealed')),
})
JS);
expect($state)->toBe([
'enhancementEnabled' => false,
'pagesFinal' => true,
'revealsFinal' => true,
]);
});
it('allows cta activation while its opening is in progress', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->script(<<<'JS'
() => {
const opening = document.querySelector('[data-motion="page-open"]');
opening?.classList.remove('is-active');
requestAnimationFrame(() => opening?.classList.add('is-active'));
return true;
}
JS);
$page->click('[data-testid="home-primary-cta"]')
->assertPathIs('/contato');
});
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
$routes = [
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
'/__missing-public-motion',
];
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
foreach ($routes as $route) {
$page = $this->visit($route, [
'reducedMotion' => 'no-preference',
])->resize($width, $height);
$overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth');
expect($overflow)->toBeLessThanOrEqual(0);
}
}
});
it('updates the active chapter while scrolling the home dossier', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->resize(1440, 1000);
$before = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
$page->script(<<<'JS'
() => {
const target = document.querySelector('#method-heading');
target?.scrollIntoView({ block: 'start' });
window.dispatchEvent(new Event('scroll'));
return Boolean(target);
}
JS);
$page->script(<<<'JS'
() => new Promise((resolve) => {
let frames = 0;
const check = () => {
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
if (href === '#method-heading' || frames > 60) {
resolve(href);
return;
}
frames += 1;
requestAnimationFrame(check);
};
check();
})
JS);
$after = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
expect($before)->not->toBeNull();
expect($after)->toBe('#method-heading');
});
it('applies CSS numbering to only rendered home folios in chapter order', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
Testimonial::factory()->published()->create([
'quote' => 'Relato publicado.',
]);
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
$numbering = $page->script(<<<'JS'
() => {
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
return {
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
labels: folios.map((folio) => folio.querySelector('[data-home-folio-label]')?.textContent?.trim() ?? ''),
contents: folios.map((folio) => {
const number = folio.querySelector('[data-home-folio-number]');
return number ? getComputedStyle(number, '::before').content.replace(/["']/g, '') : '';
}),
};
}
JS);
expect($numbering['reset'])->toContain('home-chapter -1');
expect($numbering['increments'])->each->toContain('home-chapter 1');
expect($numbering['labels'])->toBe([
'Capa',
'Manifesto',
'Serviços',
'Portfólio',
'Método',
'Depoimentos',
'A Amare',
'Próximo passo',
]);
expect($numbering['contents'])->each->toBe('counter(home-chapter, decimal-leading-zero)');
});