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 */ } }