Fix card list UI layout shifts (jumps) on load
This commit fixes layout shifts that occur on card list part of the page when the page is initially loaded. - Resolve issue where card list starts with minimal width, leading to jumps in UI until correct width is calculated on medium and big screens. - Dispose of existing `ResizeObserver` properly before creating a new one. This prevents leaks and incorrect width calculations if `containerElement` changes. - Throttle resize events to minimize width/height calculation changes, enhancing performance and reducing the chances for layout shifts. Supporting CI/CD improvements: - Enable artifact upload in CI/CD even if E2E tests fail. - Distinguish uploaded artifacts by operating system for clarity.
This commit is contained in:
9
.github/workflows/tests.e2e.yaml
vendored
9
.github/workflows/tests.e2e.yaml
vendored
@@ -27,6 +27,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Output artifact directories
|
name: Output artifact directories
|
||||||
id: artifacts
|
id: artifacts
|
||||||
|
if: always() # Run even if previous steps fail because test run video is always captured
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |-
|
run: |-
|
||||||
declare -r dirs_json_file='cypress-dirs.json'
|
declare -r dirs_json_file='cypress-dirs.json'
|
||||||
@@ -49,15 +50,15 @@ jobs:
|
|||||||
echo "VIDEOS_DIR=${VIDEOS_DIR}" >> "${GITHUB_OUTPUT}"
|
echo "VIDEOS_DIR=${VIDEOS_DIR}" >> "${GITHUB_OUTPUT}"
|
||||||
-
|
-
|
||||||
name: Upload screenshots
|
name: Upload screenshots
|
||||||
if: failure() # Run only if previous step fails because screenshots will be generated only if E2E test failed
|
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: e2e-screenshots
|
name: e2e-screenshots-${{ matrix.os }}
|
||||||
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
|
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
|
||||||
-
|
-
|
||||||
name: Upload videos
|
name: Upload videos
|
||||||
if: always() # Run even if previous step fails because test run video is always captured
|
if: always() # Run even if previous steps fail because test run video is always captured
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: e2e-videos
|
name: e2e-videos-${{ matrix.os }}
|
||||||
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}
|
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export default defineConfig({
|
|||||||
specPattern: `${cypressDirs.base}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
|
specPattern: `${cypressDirs.base}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
|
||||||
supportFile: `${cypressDirs.base}/support/e2e.ts`,
|
supportFile: `${cypressDirs.base}/support/e2e.ts`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
Disabling Chrome's web security to allow for faster DOM queries to access DOM earlier than
|
||||||
|
`cy.get()`. It bypasses the usual same-origin policy constraints
|
||||||
|
*/
|
||||||
|
chromeWebSecurity: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
function getApplicationPort(): number {
|
function getApplicationPort(): number {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
|
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
|
||||||
/* We only do this if hover is truly supported; otherwise the emulator in mobile
|
/* We only do this if hover is truly supported; otherwise the emulator in mobile
|
||||||
keeps hovered style in-place even after touching, making it sticky. */
|
keeps hovered style in-place even after touching, making it sticky. */
|
||||||
#{$selector-prefix}:hover #{$selector-suffix} {
|
#{$selector-prefix}:hover #{$selector-suffix} {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
|
|
||||||
/* We only do this if hover is not supported,otherwise the desktop behavior is not
|
/* We only do this if hover is not supported,otherwise the desktop behavior is not
|
||||||
as desired; it does not get activated on hover but only during click/touch. */
|
as desired; it does not get activated on hover but only during click/touch. */
|
||||||
#{$selector-prefix}:active #{$selector-suffix} {
|
#{$selector-prefix}:active #{$selector-suffix} {
|
||||||
@@ -28,15 +31,27 @@
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin fade-transition($name) {
|
||||||
|
.#{$name}-enter-active,
|
||||||
|
.#{$name}-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.#{$name}-enter-from,
|
||||||
|
.#{$name}-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
|
@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
|
||||||
|
|
||||||
.#{$name}-enter-active,
|
.#{$name}-enter-active,
|
||||||
.#{$name}-leave-active {
|
.#{$name}-leave-active {
|
||||||
transition: all $duration;
|
transition: all $duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
.#{$name}-leave-active,
|
.#{$name}-leave-active,
|
||||||
.#{$name}-enter-from
|
.#{$name}-enter-from {
|
||||||
{
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
@if $offset-upward {
|
@if $offset-upward {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<SizeObserver v-on:widthChanged="width = $event">
|
<SizeObserver v-on:widthChanged="width = $event">
|
||||||
<!--
|
<transition name="fade-transition">
|
||||||
<div id="responsivity-debug">
|
<div v-if="width">
|
||||||
|
<!-- <div id="responsivity-debug">
|
||||||
Width: {{ width || 'undefined' }}
|
Width: {{ width || 'undefined' }}
|
||||||
Size:
|
Size:
|
||||||
<span v-if="width <= 500">small</span>
|
<span v-if="width <= 500">small</span>
|
||||||
<span v-if="width > 500 && width < 750">medium</span>
|
<span v-if="width > 500 && width < 750">medium</span>
|
||||||
<span v-if="width >= 750">big</span>
|
<span v-if="width >= 750">big</span>
|
||||||
</div>
|
</div> -->
|
||||||
-->
|
|
||||||
<div
|
<div
|
||||||
v-if="categoryIds.length > 0"
|
v-if="categoryIds.length > 0"
|
||||||
class="cards"
|
class="cards"
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="error">Something went bad 😢</div>
|
<div v-else class="error">Something went bad 😢</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</SizeObserver>
|
</SizeObserver>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const { currentState, onStateChange } = injectKey((keys) => keys.useCollectionState);
|
const { currentState, onStateChange } = injectKey((keys) => keys.useCollectionState);
|
||||||
|
|
||||||
const width = ref<number>(0);
|
const width = ref<number | undefined>();
|
||||||
|
|
||||||
const categoryIds = computed<readonly number[]>(
|
const categoryIds = computed<readonly number[]>(
|
||||||
() => currentState.value.collection.actions.map((category) => category.id),
|
() => currentState.value.collection.actions.map((category) => category.id),
|
||||||
);
|
);
|
||||||
@@ -138,4 +141,6 @@ function isClickable(element: Element) {
|
|||||||
font-size: 3.5em;
|
font-size: 3.5em;
|
||||||
font-family: $font-normal;
|
font-family: $font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include fade-transition('fade-transition');
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
|
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
||||||
|
import { throttle } from '@/presentation/components/Shared/Throttle';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: {
|
emits: {
|
||||||
@@ -34,10 +35,11 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resizeObserverReady.then(() => {
|
resizeObserverReady.then(() => {
|
||||||
observer = new ResizeObserver(updateSize);
|
disposeObserver();
|
||||||
|
observer = new ResizeObserver(throttle(updateSize, 200));
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
});
|
});
|
||||||
updateSize();
|
updateSize(); // Do not throttle, immediately inform new width
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
231
tests/e2e/card-list-layout-stability-on-load.cy.ts
Normal file
231
tests/e2e/card-list-layout-stability-on-load.cy.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
|
||||||
|
|
||||||
|
interface Stoppable {
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('card list layout stability', () => {
|
||||||
|
describe('during initial page load', () => {
|
||||||
|
const testScenarios: ReadonlyArray<{
|
||||||
|
readonly name: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}> = [
|
||||||
|
{ name: 'iPhone SE', width: 375, height: 667 },
|
||||||
|
{ name: '13-inch Laptop', width: 1280, height: 800 },
|
||||||
|
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
|
||||||
|
];
|
||||||
|
const testCleanup = new Array<Stoppable>();
|
||||||
|
afterEach(() => {
|
||||||
|
testCleanup.forEach((c) => c.stop());
|
||||||
|
testCleanup.length = 0;
|
||||||
|
});
|
||||||
|
testScenarios.forEach(({ name, width, height }) => {
|
||||||
|
it(`ensures layout stability on ${name}`, () => {
|
||||||
|
// arrange
|
||||||
|
const dimensions = new DimensionsStorage();
|
||||||
|
cy.viewport(width, height);
|
||||||
|
// act
|
||||||
|
cy.window().then((win) => {
|
||||||
|
findElementFast(win, '.cards', (cardList) => {
|
||||||
|
testCleanup.push(
|
||||||
|
new SizeMonitor().start(cardList, () => dimensions.add(captureDimensions(cardList))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
testCleanup.push(
|
||||||
|
new ContinuousRunner()
|
||||||
|
.start(() => {
|
||||||
|
/*
|
||||||
|
As Cypress does not inherently support CPU throttling, this workaround is used to
|
||||||
|
intentionally slow down Cypress's execution. It allows capturing sudden layout
|
||||||
|
issues, such as brief flashes or shifts.
|
||||||
|
*/
|
||||||
|
cy.window().then(() => {
|
||||||
|
cy.log('Throttling');
|
||||||
|
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||||
|
cy.wait(50, { log: false });
|
||||||
|
});
|
||||||
|
}, 100),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cy.visit('/');
|
||||||
|
for (const assertNextCheckpoint of Object.values(checkpoints)) {
|
||||||
|
assertNextCheckpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const widthToleranceInPx = 0;
|
||||||
|
const widthsInPx = dimensions.getUniqueWidths();
|
||||||
|
expect(isWithinTolerance(widthsInPx, widthToleranceInPx)).to.equal(true, [
|
||||||
|
`Unique width values over time: ${[...widthsInPx].join(', ')}`,
|
||||||
|
`Height changes are more than ${widthToleranceInPx}px tolerance`,
|
||||||
|
`Captured metrics: ${dimensions.toString()}`,
|
||||||
|
].join('\n\n'));
|
||||||
|
|
||||||
|
const heightToleranceInPx = 100; // Set in relation to card sizes.
|
||||||
|
// Tolerance allows for minor layout shifts without (e.g. for icon or font loading)
|
||||||
|
// false test failures. The number `100` accounts for shifts when the number of
|
||||||
|
// cards per row changes, avoiding failures for shifts less than the smallest card
|
||||||
|
// size (~175px).
|
||||||
|
const heightsInPx = dimensions.getUniqueHeights();
|
||||||
|
expect(isWithinTolerance(heightsInPx, heightToleranceInPx)).to.equal(true, [
|
||||||
|
`Unique height values over time: ${[...heightsInPx].join(', ')}`,
|
||||||
|
`Height changes are more than ${heightToleranceInPx}px tolerance`,
|
||||||
|
`Captured metrics: ${dimensions.toString()}`,
|
||||||
|
].join('\n\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
It finds a DOM element as quickly as possible.
|
||||||
|
It's crucial for detecting early layout shifts during page load,
|
||||||
|
which may be missed by standard Cypress commands such as `cy.get`, `cy.document`.
|
||||||
|
*/
|
||||||
|
function findElementFast(
|
||||||
|
win: Cypress.AUTWindow,
|
||||||
|
query: string,
|
||||||
|
handler: (element: Element) => void,
|
||||||
|
timeoutInMs = 5000,
|
||||||
|
): void {
|
||||||
|
const endTime = Date.now() + timeoutInMs;
|
||||||
|
const finder = new ContinuousRunner();
|
||||||
|
finder.start(() => {
|
||||||
|
const element = win.document.querySelector(query);
|
||||||
|
if (element) {
|
||||||
|
handler(element);
|
||||||
|
finder.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() >= endTime) {
|
||||||
|
finder.stop();
|
||||||
|
throw new Error(`Timed out. Failed to find element. Query: ${query}. Timeout: ${timeoutInMs}ms`);
|
||||||
|
}
|
||||||
|
}, 1 /* As aggressive as possible */);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DimensionsStorage {
|
||||||
|
private readonly dimensions = new Array<SizeDimensions>();
|
||||||
|
|
||||||
|
public add(newDimension: SizeDimensions): void {
|
||||||
|
if (this.dimensions.length > 0) {
|
||||||
|
const lastDimension = this.dimensions[this.dimensions.length - 1];
|
||||||
|
if (lastDimension.width === newDimension.width
|
||||||
|
&& lastDimension.height === newDimension.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cy.window().then(() => {
|
||||||
|
cy.log(`Captured: ${JSON.stringify(newDimension)}`);
|
||||||
|
});
|
||||||
|
this.dimensions.push(newDimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUniqueWidths(): readonly number[] {
|
||||||
|
return [...new Set(this.dimensions.map((d) => d.width))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUniqueHeights(): readonly number[] {
|
||||||
|
return [...new Set(this.dimensions.map((d) => d.height))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return JSON.stringify(this.dimensions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinTolerance(
|
||||||
|
numbers: readonly number[],
|
||||||
|
tolerance: number,
|
||||||
|
) {
|
||||||
|
let changeWithinTolerance = true;
|
||||||
|
const [firstValue, ...otherValues] = numbers;
|
||||||
|
let previousValue = firstValue;
|
||||||
|
otherValues.forEach((value) => {
|
||||||
|
const difference = Math.abs(value - previousValue);
|
||||||
|
if (difference > tolerance) {
|
||||||
|
changeWithinTolerance = false;
|
||||||
|
}
|
||||||
|
previousValue = value;
|
||||||
|
});
|
||||||
|
return changeWithinTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SizeDimensions {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureDimensions(element: Element): SizeDimensions {
|
||||||
|
const dimensions = element.getBoundingClientRect(); // more reliable than body.scroll...
|
||||||
|
return {
|
||||||
|
width: Math.round(dimensions.width),
|
||||||
|
height: Math.round(dimensions.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApplicationLoadStep {
|
||||||
|
IndexHtmlLoaded = 0,
|
||||||
|
AppVueLoaded = 1,
|
||||||
|
HeaderBrandTitleLoaded = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkpoints: Record<ApplicationLoadStep, () => void> = {
|
||||||
|
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
|
||||||
|
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
|
||||||
|
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
|
||||||
|
};
|
||||||
|
|
||||||
|
class ContinuousRunner implements Stoppable {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
public start(callback: () => void, intervalInMs: number): this {
|
||||||
|
this.stop();
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
if (this.isStopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}, intervalInMs);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (this.timer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isStopped() {
|
||||||
|
return this.timer === undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SizeMonitor implements Stoppable {
|
||||||
|
private observer: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
public start(element: Element, sizeChangedCallback: () => void): this {
|
||||||
|
this.stop();
|
||||||
|
this.observer = new ResizeObserver(() => {
|
||||||
|
if (this.isStopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sizeChangedCallback();
|
||||||
|
});
|
||||||
|
this.observer.observe(element);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.observer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isStopped() {
|
||||||
|
return this.observer === undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
|
||||||
|
|
||||||
describe('application is initialized as expected', () => {
|
describe('application is initialized as expected', () => {
|
||||||
it('loads title as expected', () => {
|
it('loads title as expected', () => {
|
||||||
// act
|
// act
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
// assert
|
// assert
|
||||||
cy.contains('h1', 'privacy.sexy');
|
waitForHeaderBrandTitle();
|
||||||
});
|
});
|
||||||
it('there are no console.error output', () => {
|
it('there are no console.error output', () => {
|
||||||
// act
|
// act
|
||||||
|
|||||||
3
tests/e2e/shared/ApplicationLoad.ts
Normal file
3
tests/e2e/shared/ApplicationLoad.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function waitForHeaderBrandTitle() {
|
||||||
|
cy.contains('h1', 'privacy.sexy');
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Ref, nextTick, ref } from 'vue';
|
||||||
|
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||||
|
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||||
|
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { createSizeObserverStub } from '@tests/unit/shared/Stubs/SizeObserverStub';
|
||||||
|
|
||||||
|
const DOM_SELECTOR_CARDS = '.cards';
|
||||||
|
|
||||||
|
describe('CardList.vue', () => {
|
||||||
|
describe('rendering cards based on width', () => {
|
||||||
|
it('renders cards when a valid width is provided', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCardsExistence = true;
|
||||||
|
const width = ref(0);
|
||||||
|
// act
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
widthRef: width,
|
||||||
|
});
|
||||||
|
width.value = 800;
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
const actual = wrapper.find(DOM_SELECTOR_CARDS).exists();
|
||||||
|
expect(actual).to.equal(expectedCardsExistence, wrapper.html());
|
||||||
|
});
|
||||||
|
it('does not render cards when width is not set', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCardsExistence = false;
|
||||||
|
const width = ref(0);
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
widthRef: width,
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
const actual = wrapper.find(DOM_SELECTOR_CARDS).exists();
|
||||||
|
expect(actual).to.equal(expectedCardsExistence, wrapper.html());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountComponent(options?: {
|
||||||
|
readonly useCollectionState?: ReturnType<typeof useCollectionState>,
|
||||||
|
readonly widthRef?: Readonly<Ref<number>>,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
name: sizeObserverName,
|
||||||
|
component: sizeObserverStub,
|
||||||
|
} = createSizeObserverStub(options?.widthRef);
|
||||||
|
return shallowMount(CardList, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[InjectionKeys.useCollectionState.key]:
|
||||||
|
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
[sizeObserverName]: sizeObserverStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
|||||||
return this.collection.os;
|
return this.collection.os;
|
||||||
}
|
}
|
||||||
|
|
||||||
public collection: ICategoryCollection = new CategoryCollectionStub();
|
public collection: ICategoryCollection = new CategoryCollectionStub().withSomeActions();
|
||||||
|
|
||||||
public selection: IUserSelection = new UserSelectionStub([]);
|
public selection: IUserSelection = new UserSelectionStub([]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export class CategoryCollectionStub implements ICategoryCollection {
|
|||||||
|
|
||||||
public readonly actions = new Array<ICategory>();
|
public readonly actions = new Array<ICategory>();
|
||||||
|
|
||||||
|
public withSomeActions(): this {
|
||||||
|
this.withAction(new CategoryStub(1));
|
||||||
|
this.withAction(new CategoryStub(2));
|
||||||
|
this.withAction(new CategoryStub(3));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public withAction(category: ICategory): this {
|
public withAction(category: ICategory): this {
|
||||||
this.actions.push(category);
|
this.actions.push(category);
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
27
tests/unit/shared/Stubs/SizeObserverStub.ts
Normal file
27
tests/unit/shared/Stubs/SizeObserverStub.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineComponent, ref, watch } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
const COMPONENT_SIZE_OBSERVER_NAME = 'SizeObserver';
|
||||||
|
|
||||||
|
export function createSizeObserverStub(
|
||||||
|
widthRef: Readonly<Ref<number>> = ref(500),
|
||||||
|
) {
|
||||||
|
const component = defineComponent({
|
||||||
|
name: COMPONENT_SIZE_OBSERVER_NAME,
|
||||||
|
template: `<div id="${COMPONENT_SIZE_OBSERVER_NAME}-stub"><slot /></div>`,
|
||||||
|
emits: {
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
widthChanged: (newWidth: number) => true,
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
|
},
|
||||||
|
setup: (_, { emit }) => {
|
||||||
|
watch(widthRef, (newValue) => {
|
||||||
|
emit('widthChanged', newValue);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: COMPONENT_SIZE_OBSERVER_NAME,
|
||||||
|
component,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user