292 lines
9.9 KiB
PHP
292 lines
9.9 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'])->toBeFalse();
|
|
expect($state['hasActiveChapter'])->toBeFalse();
|
|
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('keeps rendered home chapters free of ornamental folios and counters', function (): void {
|
|
PortfolioCase::factory()->published()->create([
|
|
'is_featured' => true,
|
|
]);
|
|
Testimonial::factory()->published()->create([
|
|
'quote' => 'Relato publicado.',
|
|
]);
|
|
|
|
$page = $this->visit('/', [
|
|
'reducedMotion' => 'reduce',
|
|
]);
|
|
|
|
$editorial = $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),
|
|
folioCount: folios.length,
|
|
};
|
|
}
|
|
JS);
|
|
|
|
expect($editorial['reset'])->toBe('none');
|
|
expect($editorial['increments'])->each->toBe('none');
|
|
expect($editorial['folioCount'])->toBe(0);
|
|
});
|