From 6a20d804dc365d22c1248d787f9912271f508eeb Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Mon, 14 Aug 2023 15:28:15 +0200 Subject: [PATCH] Refactor filter (search query) event handling Refactor filter event handling to a unified event with visitor pattern to simplify the code, avoid future bugs and provide better test coverage. This commit shifts from using separate `filtered` and `filterRemoved` events to a singular, more expressive `filterChanged` event. The new approach emits a detailed payload that explicitly indicates the filter action and the associated filter data. The event object unifies the way the presentation layer reacts to the events. Benefits with this approach include: - Simplifying event listeners by reducing the number of events to handle. - Increasing code clarity and reduces potential for oversight by providing explicit action details in the event payload. - Offering extensibility for future actions without introducing new events. - Providing visitor pattern to handle different kind of events in easy and robust manner without code repetition. Other changes: - Refactor components handling of events to follow DRY and KISS principles better. - Refactor `UserFilter.spec.ts` to: - Make it easier to add new tests. - Increase code coverage by running all event-based tests on the current property. --- .../State/Filter/Event/FilterActionType.ts | 4 + .../State/Filter/Event/FilterChange.ts | 37 +++ .../Filter/Event/IFilterChangeDetails.ts | 14 + .../Context/State/Filter/IUserFilter.ts | 8 +- .../Context/State/Filter/UserFilter.ts | 16 +- .../Scripts/Menu/TheScriptsMenu.vue | 20 +- .../Scripts/View/ScriptsTree/ScriptsTree.vue | 37 +-- .../Scripts/View/TheScriptsView.vue | 28 +- src/presentation/components/TheSearchBar.vue | 35 +-- .../Context/ApplicationContext.spec.ts | 8 +- .../State/CategoryCollectionState.spec.ts | 8 +- .../State/Filter/Event/FilterChange.spec.ts | 122 ++++++++ .../Context/State/Filter/UserFilter.spec.ts | 291 +++++++++--------- .../shared/Stubs/FilterChangeDetailsStub.ts | 0 .../Stubs/FilterChangeDetailsVisitorStub.ts | 18 ++ tests/unit/shared/Stubs/FilterResultStub.ts | 44 +++ tests/unit/shared/Stubs/UserFilterStub.ts | 27 +- 17 files changed, 488 insertions(+), 229 deletions(-) create mode 100644 src/application/Context/State/Filter/Event/FilterActionType.ts create mode 100644 src/application/Context/State/Filter/Event/FilterChange.ts create mode 100644 src/application/Context/State/Filter/Event/IFilterChangeDetails.ts create mode 100644 tests/unit/application/Context/State/Filter/Event/FilterChange.spec.ts create mode 100644 tests/unit/shared/Stubs/FilterChangeDetailsStub.ts create mode 100644 tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub.ts create mode 100644 tests/unit/shared/Stubs/FilterResultStub.ts diff --git a/src/application/Context/State/Filter/Event/FilterActionType.ts b/src/application/Context/State/Filter/Event/FilterActionType.ts new file mode 100644 index 00000000..a8381927 --- /dev/null +++ b/src/application/Context/State/Filter/Event/FilterActionType.ts @@ -0,0 +1,4 @@ +export enum FilterActionType { + Apply, + Clear, +} diff --git a/src/application/Context/State/Filter/Event/FilterChange.ts b/src/application/Context/State/Filter/Event/FilterChange.ts new file mode 100644 index 00000000..e90eb2a9 --- /dev/null +++ b/src/application/Context/State/Filter/Event/FilterChange.ts @@ -0,0 +1,37 @@ +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { FilterActionType } from './FilterActionType'; +import { IFilterChangeDetails, IFilterChangeDetailsVisitor } from './IFilterChangeDetails'; + +export class FilterChange implements IFilterChangeDetails { + public static forApply(filter: IFilterResult) { + if (!filter) { + throw new Error('missing filter'); + } + return new FilterChange(FilterActionType.Apply, filter); + } + + public static forClear() { + return new FilterChange(FilterActionType.Clear); + } + + private constructor( + public readonly actionType: FilterActionType, + public readonly filter?: IFilterResult, + ) { } + + public visit(visitor: IFilterChangeDetailsVisitor): void { + if (!visitor) { + throw new Error('missing visitor'); + } + switch (this.actionType) { + case FilterActionType.Apply: + visitor.onApply(this.filter); + break; + case FilterActionType.Clear: + visitor.onClear(); + break; + default: + throw new Error(`Unknown action type: ${this.actionType}`); + } + } +} diff --git a/src/application/Context/State/Filter/Event/IFilterChangeDetails.ts b/src/application/Context/State/Filter/Event/IFilterChangeDetails.ts new file mode 100644 index 00000000..2bb32bbe --- /dev/null +++ b/src/application/Context/State/Filter/Event/IFilterChangeDetails.ts @@ -0,0 +1,14 @@ +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { FilterActionType } from './FilterActionType'; + +export interface IFilterChangeDetails { + readonly actionType: FilterActionType; + readonly filter?: IFilterResult; + + visit(visitor: IFilterChangeDetailsVisitor): void; +} + +export interface IFilterChangeDetailsVisitor { + onClear(): void; + onApply(filter: IFilterResult): void; +} diff --git a/src/application/Context/State/Filter/IUserFilter.ts b/src/application/Context/State/Filter/IUserFilter.ts index ebe4c127..60403187 100644 --- a/src/application/Context/State/Filter/IUserFilter.ts +++ b/src/application/Context/State/Filter/IUserFilter.ts @@ -1,13 +1,13 @@ import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IFilterResult } from './IFilterResult'; +import { IFilterChangeDetails } from './Event/IFilterChangeDetails'; export interface IReadOnlyUserFilter { readonly currentFilter: IFilterResult | undefined; - readonly filtered: IEventSource; - readonly filterRemoved: IEventSource; + readonly filterChanged: IEventSource; } export interface IUserFilter extends IReadOnlyUserFilter { - setFilter(filter: string): void; - removeFilter(): void; + applyFilter(filter: string): void; + clearFilter(): void; } diff --git a/src/application/Context/State/Filter/UserFilter.ts b/src/application/Context/State/Filter/UserFilter.ts index afeebd4e..5e1e026d 100644 --- a/src/application/Context/State/Filter/UserFilter.ts +++ b/src/application/Context/State/Filter/UserFilter.ts @@ -4,11 +4,11 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { FilterResult } from './FilterResult'; import { IFilterResult } from './IFilterResult'; import { IUserFilter } from './IUserFilter'; +import { IFilterChangeDetails } from './Event/IFilterChangeDetails'; +import { FilterChange } from './Event/FilterChange'; export class UserFilter implements IUserFilter { - public readonly filtered = new EventSource(); - - public readonly filterRemoved = new EventSource(); + public readonly filterChanged = new EventSource(); public currentFilter: IFilterResult | undefined; @@ -16,9 +16,9 @@ export class UserFilter implements IUserFilter { } - public setFilter(filter: string): void { + public applyFilter(filter: string): void { if (!filter) { - throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter'); + throw new Error('Filter must be defined and not empty. Use clearFilter() to remove the filter'); } const filterLowercase = filter.toLocaleLowerCase(); const filteredScripts = this.collection.getAllScripts().filter( @@ -33,12 +33,12 @@ export class UserFilter implements IUserFilter { filter, ); this.currentFilter = matches; - this.filtered.notify(matches); + this.filterChanged.notify(FilterChange.forApply(this.currentFilter)); } - public removeFilter(): void { + public clearFilter(): void { this.currentFilter = undefined; - this.filterRemoved.notify(); + this.filterChanged.notify(FilterChange.forClear()); } } diff --git a/src/presentation/components/Scripts/Menu/TheScriptsMenu.vue b/src/presentation/components/Scripts/Menu/TheScriptsMenu.vue index 844fac16..5e67e4bf 100644 --- a/src/presentation/components/Scripts/Menu/TheScriptsMenu.vue +++ b/src/presentation/components/Scripts/Menu/TheScriptsMenu.vue @@ -14,7 +14,7 @@ import { defineComponent, ref, onUnmounted, inject, } from 'vue'; import { useCollectionStateKey } from '@/presentation/injectionSymbols'; -import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; import TheOsChanger from './TheOsChanger.vue'; import TheSelector from './Selector/TheSelector.vue'; import TheViewChanger from './View/TheViewChanger.vue'; @@ -31,20 +31,22 @@ export default defineComponent({ const isSearching = ref(false); onStateChange((state) => { - subscribe(state); + subscribeToFilterChanges(state.filter); }, { immediate: true }); onUnmounted(() => { unsubscribeAll(); }); - function subscribe(state: IReadOnlyCategoryCollectionState) { - events.register(state.filter.filterRemoved.on(() => { - isSearching.value = false; - })); - events.register(state.filter.filtered.on(() => { - isSearching.value = true; - })); + function subscribeToFilterChanges(filter: IReadOnlyUserFilter) { + events.register( + filter.filterChanged.on((event) => { + event.visit({ + onApply: () => { isSearching.value = true; }, + onClear: () => { isSearching.value = false; }, + }); + }), + ); } function unsubscribeAll() { diff --git a/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue b/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue index 011ad4e5..fb75df78 100644 --- a/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue +++ b/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue @@ -20,7 +20,7 @@ import { import { useCollectionStateKey } from '@/presentation/injectionSymbols'; import { IScript } from '@/domain/IScript'; import { ICategory } from '@/domain/ICategory'; -import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { @@ -64,9 +64,7 @@ export default defineComponent({ nodes.value = parseAllCategories(state.collection); } events.unsubscribeAll(); - modifyCurrentState((mutableState) => { - registerStateMutators(mutableState); - }); + subscribeToState(state); }, { immediate: true }); function toggleNodeSelection(event: INodeSelectedEvent) { @@ -99,20 +97,26 @@ export default defineComponent({ .map((selected) => getScriptNodeId(selected.script)); } - function registerStateMutators(state: ICategoryCollectionState) { + function subscribeToState(state: IReadOnlyCategoryCollectionState) { events.register( state.selection.changed.on((scripts) => handleSelectionChanged(scripts)), - state.filter.filterRemoved.on(() => handleFilterRemoved()), - state.filter.filtered.on((filterResult) => handleFiltered(filterResult)), + state.filter.filterChanged.on((event) => { + event.visit({ + onApply: (filter) => { + filterText.value = filter.query; + filtered = filter; + }, + onClear: () => { + filterText.value = ''; + }, + }); + }), ); } function setCurrentFilter(currentFilter: IFilterResult | undefined) { - if (!currentFilter) { - handleFilterRemoved(); - } else { - handleFiltered(currentFilter); - } + filtered = currentFilter; + filterText.value = currentFilter?.query || ''; } function handleSelectionChanged(selectedScripts: ReadonlyArray): void { @@ -120,15 +124,6 @@ export default defineComponent({ .map((node) => node.id); } - function handleFilterRemoved() { - filterText.value = ''; - } - - function handleFiltered(result: IFilterResult) { - filterText.value = result.query; - filtered = result; - } - return { nodes, selectedNodeIds, diff --git a/src/presentation/components/Scripts/View/TheScriptsView.vue b/src/presentation/components/Scripts/View/TheScriptsView.vue index 497e4afe..3247e209 100644 --- a/src/presentation/components/Scripts/View/TheScriptsView.vue +++ b/src/presentation/components/Scripts/View/TheScriptsView.vue @@ -40,10 +40,8 @@ import { useApplicationKey, useCollectionStateKey } from '@/presentation/injecti 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 { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; -import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; -/** Shows content of single category or many categories */ export default defineComponent({ components: { ScriptsTree, @@ -74,25 +72,29 @@ export default defineComponent({ onStateChange((newState) => { events.unsubscribeAll(); - subscribeState(newState); + subscribeToFilterChanges(newState.filter); }); function clearSearchQuery() { modifyCurrentState((state) => { const { filter } = state; - filter.removeFilter(); + filter.clearFilter(); }); } - function subscribeState(state: IReadOnlyCategoryCollectionState) { + function subscribeToFilterChanges(filter: IReadOnlyUserFilter) { events.register( - state.filter.filterRemoved.on(() => { - isSearching.value = false; - }), - state.filter.filtered.on((result: IFilterResult) => { - searchQuery.value = result.query; - isSearching.value = true; - searchHasMatches.value = result.hasAnyMatches(); + filter.filterChanged.on((event) => { + event.visit({ + onApply: (newFilter) => { + searchQuery.value = newFilter.query; + isSearching.value = true; + searchHasMatches.value = newFilter.hasAnyMatches(); + }, + onClear: () => { + isSearching.value = false; + }, + }); }), ); } diff --git a/src/presentation/components/TheSearchBar.vue b/src/presentation/components/TheSearchBar.vue index 3bef2ae9..4f55688b 100644 --- a/src/presentation/components/TheSearchBar.vue +++ b/src/presentation/components/TheSearchBar.vue @@ -21,7 +21,6 @@ import { useCollectionStateKey } from '@/presentation/injectionSymbols'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; -import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; export default defineComponent({ directives: { @@ -44,38 +43,38 @@ export default defineComponent({ modifyCurrentState((state) => { const { filter } = state; if (!newFilter) { - filter.removeFilter(); + filter.clearFilter(); } else { - filter.setFilter(newFilter); + filter.applyFilter(newFilter); } }); } onStateChange((newState) => { events.unsubscribeAll(); - subscribeSearchQuery(newState); + updateFromInitialFilter(newState.filter.currentFilter); + subscribeToFilterChanges(newState.filter); }, { immediate: true }); - function subscribeSearchQuery(newState: IReadOnlyCategoryCollectionState) { - searchQuery.value = newState.filter.currentFilter ? newState.filter.currentFilter.query : ''; - subscribeFilter(newState.filter); + function updateFromInitialFilter(filter?: IFilterResult) { + searchQuery.value = filter?.query || ''; } - function subscribeFilter(filter: IReadOnlyUserFilter) { + function subscribeToFilterChanges(filter: IReadOnlyUserFilter) { events.register( - filter.filtered.on((result) => handleFiltered(result)), - filter.filterRemoved.on(() => handleFilterRemoved()), + filter.filterChanged.on((event) => { + event.visit({ + onApply: (result) => { + searchQuery.value = result.query; + }, + onClear: () => { + searchQuery.value = ''; + }, + }); + }), ); } - function handleFilterRemoved() { - searchQuery.value = ''; - } - - function handleFiltered(result: IFilterResult) { - searchQuery.value = result.query; - } - return { searchPlaceholder, searchQuery, diff --git a/tests/unit/application/Context/ApplicationContext.spec.ts b/tests/unit/application/Context/ApplicationContext.spec.ts index 6c7043a3..80f23058 100644 --- a/tests/unit/application/Context/ApplicationContext.spec.ts +++ b/tests/unit/application/Context/ApplicationContext.spec.ts @@ -47,7 +47,7 @@ describe('ApplicationContext', () => { const sut = testContext .withInitialOs(OperatingSystem.Windows) .construct(); - sut.state.filter.setFilter('filtered'); + sut.state.filter.applyFilter('filtered'); sut.changeContext(OperatingSystem.macOS); // assert expectEmptyState(sut.state); @@ -65,10 +65,10 @@ describe('ApplicationContext', () => { .withInitialOs(os) .construct(); const firstState = sut.state; - firstState.filter.setFilter(expectedFilter); + firstState.filter.applyFilter(expectedFilter); sut.changeContext(os); sut.changeContext(changedOs); - sut.state.filter.setFilter('second-state'); + sut.state.filter.applyFilter('second-state'); sut.changeContext(os); // assert const actualFilter = sut.state.filter.currentFilter.query; @@ -103,7 +103,7 @@ describe('ApplicationContext', () => { .withInitialOs(os) .construct(); const initialState = sut.state; - initialState.filter.setFilter('dirty-state'); + initialState.filter.applyFilter('dirty-state'); sut.changeContext(os); // assert expect(testContext.firedEvents.length).to.equal(0); diff --git a/tests/unit/application/Context/State/CategoryCollectionState.spec.ts b/tests/unit/application/Context/State/CategoryCollectionState.spec.ts index 79235881..c10898dc 100644 --- a/tests/unit/application/Context/State/CategoryCollectionState.spec.ts +++ b/tests/unit/application/Context/State/CategoryCollectionState.spec.ts @@ -91,11 +91,11 @@ describe('CategoryCollectionState', () => { .withAction(new CategoryStub(0).withScript(expectedScript)); const sut = new CategoryCollectionState(collection); // act - let actualScript: IScript; - sut.filter.filtered.on((result) => { - [actualScript] = result.scriptMatches; + let actualScript: IScript | undefined; + sut.filter.filterChanged.on((result) => { + [actualScript] = result.filter?.scriptMatches ?? [undefined]; }); - sut.filter.setFilter(scriptNameFilter); + sut.filter.applyFilter(scriptNameFilter); // assert expect(expectedScript).to.equal(actualScript); }); diff --git a/tests/unit/application/Context/State/Filter/Event/FilterChange.spec.ts b/tests/unit/application/Context/State/Filter/Event/FilterChange.spec.ts new file mode 100644 index 00000000..bdb5df42 --- /dev/null +++ b/tests/unit/application/Context/State/Filter/Event/FilterChange.spec.ts @@ -0,0 +1,122 @@ +import 'mocha'; +import { expect } from 'chai'; +import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub'; +import { FilterActionType } from '@/application/Context/State/Filter/Event/FilterActionType'; +import { FilterChangeDetailsVisitorStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub'; + +describe('FilterChange', () => { + describe('forApply', () => { + describe('throws when filter is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing filter'; + const filterValue = absentValue; + // act + const act = () => FilterChange.forApply(filterValue); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('sets filter result', () => { + // arrange + const expectedFilter = new FilterResultStub(); + // act + const sut = FilterChange.forApply(expectedFilter); + // assert + const actualFilter = sut.filter; + expect(actualFilter).to.equal(expectedFilter); + }); + it('sets action as expected', () => { + // arrange + const expectedAction = FilterActionType.Apply; + // act + const sut = FilterChange.forApply(new FilterResultStub()); + // assert + const actualAction = sut.actionType; + expect(actualAction).to.equal(expectedAction); + }); + }); + describe('forClear', () => { + it('does not set filter result', () => { + // arrange + const expectedFilter = undefined; + // act + const sut = FilterChange.forClear(); + // assert + const actualFilter = sut.filter; + expect(actualFilter).to.equal(expectedFilter); + }); + it('sets action as expected', () => { + // arrange + const expectedAction = FilterActionType.Clear; + // act + const sut = FilterChange.forClear(); + // assert + const actualAction = sut.actionType; + expect(actualAction).to.equal(expectedAction); + }); + }); + describe('visit', () => { + describe('throws when visitor is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing visitor'; + const visitorValue = absentValue; + const sut = FilterChange.forClear(); + // act + const act = () => sut.visit(visitorValue); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('onClear', () => { + itVisitsOnce( + () => FilterChange.forClear(), + ); + }); + describe('onApply', () => { + itVisitsOnce( + () => FilterChange.forApply(new FilterResultStub()), + ); + + it('visits with expected filter', () => { + // arrange + const expectedFilter = new FilterResultStub(); + const sut = FilterChange.forApply(expectedFilter); + const visitor = new FilterChangeDetailsVisitorStub(); + // act + sut.visit(visitor); + // assert + expect(visitor.visitedResults).to.have.lengthOf(1); + expect(visitor.visitedResults).to.include(expectedFilter); + }); + }); + }); +}); + +function itVisitsOnce(sutFactory: () => FilterChange) { + it('visits', () => { + // arrange + const sut = sutFactory(); + const expectedType = sut.actionType; + const visitor = new FilterChangeDetailsVisitorStub(); + // act + sut.visit(visitor); + // assert + expect(visitor.visitedEvents).to.include(expectedType); + }); + it('visits once', () => { + // arrange + const sut = sutFactory(); + const expectedType = sut.actionType; + const visitor = new FilterChangeDetailsVisitorStub(); + // act + sut.visit(visitor); + // assert + expect( + visitor.visitedEvents.filter((action) => action === expectedType), + ).to.have.lengthOf(1); + }); +} diff --git a/tests/unit/application/Context/State/Filter/UserFilter.spec.ts b/tests/unit/application/Context/State/Filter/UserFilter.spec.ts index 3007c1e2..27db5590 100644 --- a/tests/unit/application/Context/State/Filter/UserFilter.spec.ts +++ b/tests/unit/application/Context/State/Filter/UserFilter.spec.ts @@ -5,173 +5,182 @@ import { UserFilter } from '@/application/Context/State/Filter/UserFilter'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; +import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange'; +import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; describe('UserFilter', () => { - describe('removeFilter', () => { + describe('clearFilter', () => { it('signals when removing filter', () => { // arrange - let isCalled = false; + const expectedChange = FilterChange.forClear(); + let actualChange: IFilterChangeDetails; const sut = new UserFilter(new CategoryCollectionStub()); - sut.filterRemoved.on(() => { - isCalled = true; + sut.filterChanged.on((change) => { + actualChange = change; }); // act - sut.removeFilter(); + sut.clearFilter(); // assert - expect(isCalled).to.be.equal(true); + expect(actualChange).to.deep.equal(expectedChange); }); it('sets currentFilter to undefined', () => { // arrange const sut = new UserFilter(new CategoryCollectionStub()); // act - sut.setFilter('non-important'); - sut.removeFilter(); + sut.applyFilter('non-important'); + sut.clearFilter(); // assert expect(sut.currentFilter).to.be.equal(undefined); }); }); - describe('setFilter', () => { - it('signals when no matches', () => { - // arrange - let actual: IFilterResult; - const nonMatchingFilter = 'non matching filter'; - const sut = new UserFilter(new CategoryCollectionStub()); - sut.filtered.on((filterResult) => { - actual = filterResult; - }); - // act - sut.setFilter(nonMatchingFilter); - // assert - expect(actual.hasAnyMatches()).be.equal(false); - expect(actual.query).to.equal(nonMatchingFilter); - }); - it('sets currentFilter as expected when no matches', () => { - // arrange - const nonMatchingFilter = 'non matching filter'; - const sut = new UserFilter(new CategoryCollectionStub()); - // act - sut.setFilter(nonMatchingFilter); - // assert - const actual = sut.currentFilter; - expect(actual.hasAnyMatches()).be.equal(false); - expect(actual.query).to.equal(nonMatchingFilter); - }); - describe('signals when matches', () => { - describe('signals when script matches', () => { - it('code matches', () => { - // arrange - const code = 'HELLO world'; - const filter = 'Hello WoRLD'; - let actual: IFilterResult; - const script = new ScriptStub('id').withCode(code); - const category = new CategoryStub(33).withScript(script); - const sut = new UserFilter(new CategoryCollectionStub() - .withAction(category)); - sut.filtered.on((filterResult) => { - actual = filterResult; - }); - // act - sut.setFilter(filter); - // assert - expect(actual.hasAnyMatches()).be.equal(true); - expect(actual.categoryMatches).to.have.lengthOf(0); - expect(actual.scriptMatches).to.have.lengthOf(1); - expect(actual.scriptMatches[0]).to.deep.equal(script); - expect(actual.query).to.equal(filter); - expect(sut.currentFilter).to.deep.equal(actual); - }); - it('revertCode matches', () => { - // arrange - const revertCode = 'HELLO world'; - const filter = 'Hello WoRLD'; - let actual: IFilterResult; - const script = new ScriptStub('id').withRevertCode(revertCode); - const category = new CategoryStub(33).withScript(script); - const sut = new UserFilter(new CategoryCollectionStub() - .withAction(category)); - sut.filtered.on((filterResult) => { - actual = filterResult; - }); - // act - sut.setFilter(filter); - // assert - expect(actual.hasAnyMatches()).be.equal(true); - expect(actual.categoryMatches).to.have.lengthOf(0); - expect(actual.scriptMatches).to.have.lengthOf(1); - expect(actual.scriptMatches[0]).to.deep.equal(script); - expect(actual.query).to.equal(filter); - expect(sut.currentFilter).to.deep.equal(actual); - }); - it('name matches', () => { - // arrange - const name = 'HELLO world'; - const filter = 'Hello WoRLD'; - let actual: IFilterResult; - const script = new ScriptStub('id').withName(name); - const category = new CategoryStub(33).withScript(script); - const sut = new UserFilter(new CategoryCollectionStub() - .withAction(category)); - sut.filtered.on((filterResult) => { - actual = filterResult; - }); - // act - sut.setFilter(filter); - // assert - expect(actual.hasAnyMatches()).be.equal(true); - expect(actual.categoryMatches).to.have.lengthOf(0); - expect(actual.scriptMatches).to.have.lengthOf(1); - expect(actual.scriptMatches[0]).to.deep.equal(script); - expect(actual.query).to.equal(filter); - expect(sut.currentFilter).to.deep.equal(actual); - }); - }); - it('signals when category matches', () => { - // arrange + describe('applyFilter', () => { + interface IApplyFilterTestCase { + readonly name: string; + readonly filter: string; + readonly collection: ICategoryCollection; + readonly assert: (result: IFilterResult) => void; + } + const testCases: readonly IApplyFilterTestCase[] = [ + (() => { + const nonMatchingFilter = 'non matching filter'; + return { + name: 'given no matches', + filter: nonMatchingFilter, + collection: new CategoryCollectionStub(), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(false); + expect(filter.query).to.equal(nonMatchingFilter); + }, + }; + })(), + (() => { + const code = 'HELLO world'; + const matchingFilter = 'Hello WoRLD'; + const script = new ScriptStub('id').withCode(code); + return { + name: 'given script match with case-insensitive code', + filter: matchingFilter, + collection: new CategoryCollectionStub() + .withAction(new CategoryStub(33).withScript(script)), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(true); + expect(filter.categoryMatches).to.have.lengthOf(0); + expect(filter.scriptMatches).to.have.lengthOf(1); + expect(filter.scriptMatches[0]).to.deep.equal(script); + expect(filter.query).to.equal(matchingFilter); + }, + }; + })(), + (() => { + const revertCode = 'HELLO world'; + const matchingFilter = 'Hello WoRLD'; + const script = new ScriptStub('id').withRevertCode(revertCode); + return { + name: 'given script match with case-insensitive revertCode', + filter: matchingFilter, + collection: new CategoryCollectionStub() + .withAction(new CategoryStub(33).withScript(script)), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(true); + expect(filter.categoryMatches).to.have.lengthOf(0); + expect(filter.scriptMatches).to.have.lengthOf(1); + expect(filter.scriptMatches[0]).to.deep.equal(script); + expect(filter.query).to.equal(matchingFilter); + }, + }; + })(), + (() => { + const name = 'HELLO world'; + const matchingFilter = 'Hello WoRLD'; + const script = new ScriptStub('id').withName(name); + return { + name: 'given script match with case-insensitive name', + filter: matchingFilter, + collection: new CategoryCollectionStub() + .withAction(new CategoryStub(33).withScript(script)), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(true); + expect(filter.categoryMatches).to.have.lengthOf(0); + expect(filter.scriptMatches).to.have.lengthOf(1); + expect(filter.scriptMatches[0]).to.deep.equal(script); + expect(filter.query).to.equal(matchingFilter); + }, + }; + })(), + (() => { const categoryName = 'HELLO world'; - const filter = 'Hello WoRLD'; - let actual: IFilterResult; + const matchingFilter = 'Hello WoRLD'; const category = new CategoryStub(55).withName(categoryName); - const sut = new UserFilter(new CategoryCollectionStub() - .withAction(category)); - sut.filtered.on((filterResult) => { - actual = filterResult; - }); - // act - sut.setFilter(filter); - // assert - expect(actual.hasAnyMatches()).be.equal(true); - expect(actual.categoryMatches).to.have.lengthOf(1); - expect(actual.categoryMatches[0]).to.deep.equal(category); - expect(actual.scriptMatches).to.have.lengthOf(0); - expect(actual.query).to.equal(filter); - expect(sut.currentFilter).to.deep.equal(actual); - }); - it('signals when category and script matches', () => { - // arrange + return { + name: 'given category match with case-insensitive name', + filter: matchingFilter, + collection: new CategoryCollectionStub() + .withAction(category), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(true); + expect(filter.categoryMatches).to.have.lengthOf(1); + expect(filter.categoryMatches[0]).to.deep.equal(category); + expect(filter.scriptMatches).to.have.lengthOf(0); + expect(filter.query).to.equal(matchingFilter); + }, + }; + })(), + (() => { const matchingText = 'HELLO world'; - const filter = 'Hello WoRLD'; - let actual: IFilterResult; + const matchingFilter = 'Hello WoRLD'; const script = new ScriptStub('script') .withName(matchingText); const category = new CategoryStub(55) .withName(matchingText) .withScript(script); - const collection = new CategoryCollectionStub() - .withAction(category); - const sut = new UserFilter(collection); - sut.filtered.on((filterResult) => { - actual = filterResult; + return { + name: 'given category and script matches with case-insensitive names', + filter: matchingFilter, + collection: new CategoryCollectionStub() + .withAction(category), + assert: (filter) => { + expect(filter.hasAnyMatches()).be.equal(true); + expect(filter.categoryMatches).to.have.lengthOf(1); + expect(filter.categoryMatches[0]).to.deep.equal(category); + expect(filter.scriptMatches).to.have.lengthOf(1); + expect(filter.scriptMatches[0]).to.deep.equal(script); + expect(filter.query).to.equal(matchingFilter); + }, + }; + })(), + ]; + describe('sets currentFilter as expected', () => { + testCases.forEach(({ + name, filter, collection, assert, + }) => { + it(name, () => { + // arrange + const sut = new UserFilter(collection); + // act + sut.applyFilter(filter); + // assert + const actual = sut.currentFilter; + assert(actual); + }); + }); + }); + describe('signals as expected', () => { + testCases.forEach(({ + name, filter, collection, assert, + }) => { + it(name, () => { + // arrange + const sut = new UserFilter(collection); + let actualFilterResult: IFilterResult; + sut.filterChanged.on((filterResult) => { + actualFilterResult = filterResult.filter; + }); + // act + sut.applyFilter(filter); + // assert + assert(actualFilterResult); }); - // act - sut.setFilter(filter); - // assert - expect(actual.hasAnyMatches()).be.equal(true); - expect(actual.categoryMatches).to.have.lengthOf(1); - expect(actual.categoryMatches[0]).to.deep.equal(category); - expect(actual.scriptMatches).to.have.lengthOf(1); - expect(actual.scriptMatches[0]).to.deep.equal(script); - expect(actual.query).to.equal(filter); - expect(sut.currentFilter).to.deep.equal(actual); }); }); }); diff --git a/tests/unit/shared/Stubs/FilterChangeDetailsStub.ts b/tests/unit/shared/Stubs/FilterChangeDetailsStub.ts new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub.ts b/tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub.ts new file mode 100644 index 00000000..8ee90a76 --- /dev/null +++ b/tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub.ts @@ -0,0 +1,18 @@ +import { FilterActionType } from '@/application/Context/State/Filter/Event/FilterActionType'; +import { IFilterChangeDetailsVisitor } from '@/application/Context/State/Filter/Event/IFilterChangeDetails'; +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; + +export class FilterChangeDetailsVisitorStub implements IFilterChangeDetailsVisitor { + public readonly visitedEvents = new Array(); + + public readonly visitedResults = new Array(); + + onClear(): void { + this.visitedEvents.push(FilterActionType.Clear); + } + + onApply(filter: IFilterResult): void { + this.visitedEvents.push(FilterActionType.Apply); + this.visitedResults.push(filter); + } +} diff --git a/tests/unit/shared/Stubs/FilterResultStub.ts b/tests/unit/shared/Stubs/FilterResultStub.ts new file mode 100644 index 00000000..1e01e9cc --- /dev/null +++ b/tests/unit/shared/Stubs/FilterResultStub.ts @@ -0,0 +1,44 @@ +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { ICategory } from '@/domain/ICategory'; +import { IScript } from '@/domain/IScript'; +import { CategoryStub } from './CategoryStub'; +import { ScriptStub } from './ScriptStub'; + +export class FilterResultStub implements IFilterResult { + public categoryMatches: readonly ICategory[] = []; + + public scriptMatches: readonly IScript[] = []; + + public query = ''; + + public withEmptyMatches() { + return this + .withCategoryMatches([]) + .withScriptMatches([]); + } + + public withSomeMatches() { + return this + .withCategoryMatches([new CategoryStub(3).withScriptIds('script-1')]) + .withScriptMatches([new ScriptStub('script-2')]); + } + + public withCategoryMatches(matches: readonly ICategory[]) { + this.categoryMatches = matches; + return this; + } + + public withScriptMatches(matches: readonly IScript[]) { + this.scriptMatches = matches; + return this; + } + + public withQuery(query: string) { + this.query = query; + return this; + } + + public hasAnyMatches(): boolean { + return this.categoryMatches.length > 0 || this.scriptMatches.length > 0; + } +} diff --git a/tests/unit/shared/Stubs/UserFilterStub.ts b/tests/unit/shared/Stubs/UserFilterStub.ts index 5333fbfa..78036bd7 100644 --- a/tests/unit/shared/Stubs/UserFilterStub.ts +++ b/tests/unit/shared/Stubs/UserFilterStub.ts @@ -1,19 +1,32 @@ import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter'; import { IEventSource } from '@/infrastructure/Events/IEventSource'; +import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails'; +import { FilterResultStub } from './FilterResultStub'; +import { EventSourceStub } from './EventSourceStub'; export class UserFilterStub implements IUserFilter { - public currentFilter: IFilterResult; + private readonly filterChangedSource = new EventSourceStub(); - public filtered: IEventSource; + public currentFilter: IFilterResult | undefined = new FilterResultStub(); - public filterRemoved: IEventSource; + public filterChanged: IEventSource = this.filterChangedSource; - public setFilter(): void { - throw new Error('Method not implemented.'); + public notifyFilterChange(change: IFilterChangeDetails) { + this.filterChangedSource.notify(change); + this.currentFilter = change.filter; } - public removeFilter(): void { - throw new Error('Method not implemented.'); + public withNoCurrentFilter() { + return this.withCurrentFilterResult(undefined); } + + public withCurrentFilterResult(filter: IFilterResult | undefined) { + this.currentFilter = filter; + return this; + } + + public applyFilter(): void { /* NO OP */ } + + public clearFilter(): void { /* NO OP */ } }