Files
privacy.sexy/tests/unit/presentation/components/Scripts/View/TheScriptsView.spec.ts
undergroundwires eb096d07e2 Fix memory leaks via auto-unsubscribing and DI
This commit simplifies event handling, providing a unified and robust
way to handle event lifecycling. This way, it fixes events not being
unsubscribed when state is changed.

Introduce a new function in `EventSubscriptionCollection` to remove
existing events and adding new events. This provides an easier to use
API, which leads to code that's easier to understand. It also prevents
potential bugs that may occur due to forgetting to call both functions.
It fixes `TheScriptsMenu` not unregistering events on state change.
Other improvements include:
  - Include a getter to get total amount of registered subcriptions.
    This helps in unit testing.
  - Have nullish checks to prevent potential errors further down the
    execution.
  - Use array instead of rest parameters to increase readability and
    simplify tests.

Ensure `SliderHandler` stops resizes on unmount, unsubscribing from all
events and resetting state to default.

Update `injectionKeys` to do imports as types to avoid circular
dependencies. Simplify importing `injectionKeys` to enable and strict
typings for iterating injection keys.

Add tests covering new behavior.
2023-09-01 18:14:25 +02:00

420 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { Wrapper, shallowMount } from '@vue/test-utils';
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseApplicationStub } from '@tests/unit/shared/Stubs/UseApplicationStub';
import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
const DOM_SELECTOR_NO_MATCHES = '.search-no-matches';
const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button';
describe('TheScriptsView.vue', () => {
describe('view types', () => {
describe('initially', () => {
interface IInitialViewTypeTestCase {
readonly initialView: ViewType;
readonly expectedComponent: unknown;
readonly absentComponents: readonly unknown[];
}
const testCases: readonly IInitialViewTypeTestCase[] = [
{
initialView: ViewType.Tree,
expectedComponent: ScriptsTree,
absentComponents: [CardList],
},
{
initialView: ViewType.Cards,
expectedComponent: CardList,
absentComponents: [ScriptsTree],
},
];
testCases.forEach(({ initialView, expectedComponent, absentComponents }) => {
it(`given initial view, renders only ${ViewType[initialView]}`, () => {
// act
const wrapper = mountComponent({
viewType: initialView,
});
// assert
expect(wrapper.findComponent(expectedComponent).exists()).to.equal(true);
expectComponentsToNotExist(wrapper, absentComponents);
});
});
});
describe('toggle view type', () => {
interface IToggleViewTypeTestCase {
readonly originalView: ViewType;
readonly newView: ViewType;
readonly absentComponents: readonly unknown[];
readonly expectedComponent: unknown;
}
const toggleTestCases: IToggleViewTypeTestCase[] = [
{
originalView: ViewType.Tree,
newView: ViewType.Cards,
absentComponents: [ScriptsTree],
expectedComponent: CardList,
},
{
originalView: ViewType.Cards,
newView: ViewType.Tree,
absentComponents: [CardList],
expectedComponent: ScriptsTree,
},
];
toggleTestCases.forEach(({
originalView, newView, absentComponents, expectedComponent,
}) => {
it(`toggles from ${ViewType[originalView]} to ${ViewType[newView]}`, async () => {
// arrange
// act
const wrapper = mountComponent({
viewType: originalView,
});
await wrapper.setProps({ currentView: newView });
// assert
expect(wrapper.findComponent(expectedComponent).exists()).to.equal(true);
expectComponentsToNotExist(wrapper, absentComponents);
});
});
});
});
describe('switching views', () => {
interface ISwitchingViewTestCase {
readonly name: string;
readonly initialView: ViewType;
readonly changeEvents: readonly IFilterChangeDetails[];
readonly componentsToDisappear: readonly unknown[];
readonly expectedComponent: unknown;
readonly setupFilter?: (filter: UserFilterStub) => UserFilterStub;
}
const testCases: readonly ISwitchingViewTestCase[] = [
{
name: 'tree on initial search with card view',
initialView: ViewType.Cards,
setupFilter: (filter: UserFilterStub) => filter
.withCurrentFilterResult(
new FilterResultStub().withQueryAndSomeMatches(),
),
changeEvents: [],
expectedComponent: ScriptsTree,
componentsToDisappear: [CardList],
},
{
name: 'restore card after initial search',
initialView: ViewType.Cards,
setupFilter: (filter: UserFilterStub) => filter
.withCurrentFilterResult(
new FilterResultStub().withQueryAndSomeMatches(),
),
changeEvents: [
FilterChange.forClear(),
],
expectedComponent: CardList,
componentsToDisappear: [ScriptsTree],
},
{
name: 'tree on search',
initialView: ViewType.Cards,
changeEvents: [
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
],
expectedComponent: ScriptsTree,
componentsToDisappear: [CardList],
},
{
name: 'return to card after search',
initialView: ViewType.Cards,
changeEvents: [
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
FilterChange.forClear(),
],
expectedComponent: CardList,
componentsToDisappear: [ScriptsTree],
},
{
name: 'return to tree after search',
initialView: ViewType.Tree,
changeEvents: [
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
FilterChange.forClear(),
],
expectedComponent: ScriptsTree,
componentsToDisappear: [CardList],
},
];
testCases.forEach(({
name, initialView, changeEvents, expectedComponent: componentToAppear,
componentsToDisappear, setupFilter,
}) => {
it(name, async () => {
// arrange
let filterStub = new UserFilterStub();
if (setupFilter) {
filterStub = setupFilter(filterStub);
}
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
viewType: initialView,
});
// act
for (const changeEvent of changeEvents) {
filterStub.notifyFilterChange(changeEvent);
// eslint-disable-next-line no-await-in-loop
await wrapper.vm.$nextTick();
}
// assert
expect(wrapper.findComponent(componentToAppear).exists()).to.equal(true);
expectComponentsToNotExist(wrapper, componentsToDisappear);
});
});
});
describe('close button', () => {
describe('visibility', () => {
describe('does not show close button when not searching', () => {
it('not searching initially', () => {
// arrange
const filterStub = new UserFilterStub()
.withNoCurrentFilter();
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
// act
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// assert
const closeButton = wrapper.find(DOM_SELECTOR_CLOSE_BUTTON);
expect(closeButton.exists()).to.equal(false);
});
it('stop searching', async () => {
// arrange
const filterStub = new UserFilterStub();
const stateStub = new UseCollectionStateStub().withFilter(filterStub);
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// act
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
filterStub.notifyFilterChange(FilterChange.forClear());
await wrapper.vm.$nextTick();
// assert
const closeButton = wrapper.find(DOM_SELECTOR_CLOSE_BUTTON);
expect(closeButton.exists()).to.equal(false);
});
});
describe('shows close button when searching', () => {
it('searching initially', () => {
// arrange
const filterStub = new UserFilterStub()
.withCurrentFilterResult(
new FilterResultStub().withQueryAndSomeMatches(),
);
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
// act
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// assert
const closeButton = wrapper.find(DOM_SELECTOR_CLOSE_BUTTON);
expect(closeButton.exists()).to.equal(true);
});
it('start searching', async () => {
// arrange
const filterStub = new UserFilterStub()
.withNoCurrentFilter();
const stateStub = new UseCollectionStateStub().withFilter(filterStub);
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// act
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
// assert
const closeButton = wrapper.find(DOM_SELECTOR_CLOSE_BUTTON);
expect(closeButton.exists()).to.equal(true);
});
});
});
it('clears search query on close button click', async () => {
// arrange
const filterStub = new UserFilterStub();
const stateStub = new UseCollectionStateStub().withFilter(filterStub);
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
filterStub.callHistory.length = 0;
// act
const closeButton = wrapper.find(DOM_SELECTOR_CLOSE_BUTTON);
await closeButton.trigger('click');
// assert
expect(filterStub.callHistory).to.have.lengthOf(1);
expect(filterStub.callHistory).to.include(UserFilterMethod.ClearFilter);
});
});
describe('no matches text', () => {
interface INoMatchesTextTestCase {
readonly name: string;
readonly filter: IFilterResult;
readonly shouldNoMatchesExist: boolean;
}
const commonTestCases: readonly INoMatchesTextTestCase[] = [
{
name: 'shows text given no matches',
filter: new FilterResultStub()
.withQuery('non-empty query')
.withEmptyMatches(),
shouldNoMatchesExist: true,
},
{
name: 'does not show text given some matches',
filter: new FilterResultStub().withQueryAndSomeMatches(),
shouldNoMatchesExist: false,
},
];
describe('initial state', () => {
const initialStateTestCases: readonly INoMatchesTextTestCase[] = [
...commonTestCases,
{
name: 'does not show text given no filter',
filter: undefined,
shouldNoMatchesExist: false,
},
];
initialStateTestCases.forEach(({ name, filter, shouldNoMatchesExist }) => {
it(name, () => {
// arrange
const expected = shouldNoMatchesExist;
const stateStub = new UseCollectionStateStub()
.withFilterResult(filter);
// act
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// expect
const actual = wrapper.find(DOM_SELECTOR_NO_MATCHES).exists();
expect(actual).to.equal(expected);
});
});
});
describe('on state change', () => {
commonTestCases.forEach(({ name, filter, shouldNoMatchesExist }) => {
it(name, async () => {
// arrange
const expected = shouldNoMatchesExist;
const filterStub = new UserFilterStub();
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
// act
filterStub.notifyFilterChange(FilterChange.forApply(
filter,
));
await wrapper.vm.$nextTick();
// expect
const actual = wrapper.find(DOM_SELECTOR_NO_MATCHES).exists();
expect(actual).to.equal(expected);
});
});
it('shows no text if filter is removed after matches', async () => {
// arrange
const filter = new UserFilterStub();
const stub = new UseCollectionStateStub()
.withFilter(filter);
const wrapper = mountComponent({
useCollectionState: stub.get(),
});
// act
filter.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withSomeMatches(),
));
filter.notifyFilterChange(FilterChange.forClear());
await wrapper.vm.$nextTick();
// expect
expect(wrapper.find(DOM_SELECTOR_NO_MATCHES).exists()).to.equal(false);
});
});
});
});
function expectComponentsToNotExist(wrapper: Wrapper<Vue>, components: readonly unknown[]) {
const existingUnexpectedComponents = components
.map((component) => wrapper.findComponent(component))
.filter((component) => component.exists());
expect(existingUnexpectedComponents).to.have.lengthOf(0);
}
function mountComponent(options?: {
readonly useCollectionState?: ReturnType<typeof useCollectionState>,
readonly viewType?: ViewType,
}) {
return shallowMount(TheScriptsView, {
provide: {
[InjectionKeys.useCollectionState as symbol]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
[InjectionKeys.useApplication as symbol]:
new UseApplicationStub().get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
propsData: {
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
},
});
}