Refactor DI for simplicity and type safety

This commit improves the dependency injection mechanism by introducing a
custom `injectKey` function.

Key improvements are:

- Enforced type consistency during dependency registration and
  instantiation.
- Simplified injection process, abstracting away the complexity with a
  uniform API, regardless of the dependency's lifetime.
- Eliminated the possibility of `undefined` returns during dependency
  injection, promoting fail-fast behavior.
- Removed the necessity for type casting to `symbol` for injection keys
  in unit tests by using existing types.
- Consalidated imports, combining keys and injection functions in one
  `import` statement.
This commit is contained in:
undergroundwires
2023-11-09 13:17:38 +01:00
parent aab0f7ea46
commit 7770a9b521
51 changed files with 398 additions and 177 deletions

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { BootstrapperStub } from '@tests/unit/shared/Stubs/BootstrapperStub';
import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import type { App } from 'vue';
describe('ApplicationBootstrapper', () => {

View File

@@ -18,8 +18,9 @@ describe('DependencyProvider', () => {
useCurrentCode: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
describe(`Key: "${key}"`, () => {
runTests(InjectionKeys[key]);
const registeredKey = InjectionKeys[key].key;
describe(`Key: "${registeredKey.toString()}"`, () => {
runTests(registeredKey);
});
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator';
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
describe('RuntimeSanityValidator', () => {
it('calls validator with correct options upon bootstrap', async () => {

View File

@@ -39,12 +39,12 @@ function mountComponent(options?: {
return shallowMount(CodeCopyButton, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]: () => (
[InjectionKeys.useClipboard.key]: () => (
options?.clipboard
? new UseClipboardStub(options.clipboard)
: new UseClipboardStub()
).get(),
[InjectionKeys.useCurrentCode as symbol]: () => (
[InjectionKeys.useCurrentCode.key]: () => (
options.currentCode === undefined
? new UseCurrentCodeStub()
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
@@ -74,7 +74,7 @@ function mountComponent(options?: {
return shallowMount(CodeInstruction, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]:
[InjectionKeys.useClipboard.key]:
() => {
if (options?.clipboard) {
return new UseClipboardStub(options.clipboard).get();

View File

@@ -406,11 +406,11 @@ function mountComponent(options?: {
return shallowMount(TheScriptsView, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]:
[InjectionKeys.useCollectionState.key]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
[InjectionKeys.useApplication as symbol]:
[InjectionKeys.useApplication.key]:
new UseApplicationStub().get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},

View File

@@ -79,7 +79,7 @@ function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undef
{
global: {
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},

View File

@@ -70,7 +70,7 @@ function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
{
global: {
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},

View File

@@ -336,7 +336,7 @@ class UseNodeStateChangeAggregatorBuilder {
{
global: {
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => this.events.get(),
},
},

View File

@@ -172,7 +172,7 @@ function mountWrapperComponent() {
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
},
},
});

View File

@@ -239,9 +239,9 @@ function mountWrapperComponent(scenario?: {
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]:
[InjectionKeys.useCollectionState.key]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
},
},

View File

@@ -148,9 +148,9 @@ function mountWrapperComponent(options?: {
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]:
[InjectionKeys.useCollectionState.key]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},

View File

@@ -106,7 +106,7 @@ function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
},
},
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { BrowserClipboard, NavigatorClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
describe('BrowserClipboard', () => {
describe('writeText', () => {

View File

@@ -1,23 +1,77 @@
import { describe, it, expect } from 'vitest';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
import { getAbsentObjectTestCases } from '../shared/TestCases/AbsentTests';
describe('injectionSymbols', () => {
Object.entries(InjectionKeys).forEach(([key, symbol]) => {
describe(`symbol for ${key}`, () => {
it('should be a symbol type', () => {
// arrange
const expectedType = Symbol;
// act
// assert
expect(symbol).to.be.instanceOf(expectedType);
describe('InjectionKeys', () => {
Object.entries(InjectionKeys).forEach(([key, injectionKey]) => {
describe(`symbol for ${injectionKey.key.toString()}`, () => {
it('should be a symbol type', () => {
// arrange
const actualKey = injectionKey.key;
const expectedType = Symbol;
// act
// assert
expect(actualKey).to.be.instanceOf(expectedType);
});
it(`should have a description matching the key "${injectionKey.key.toString()}"`, () => {
// arrange
const actualKey = injectionKey.key;
const expected = key;
// act
const actual = actualKey.description;
// assert
expect(expected).to.equal(actual);
});
});
it(`should have a description matching the key "${key}"`, () => {
// arrange
const expected = key;
// act
const actual = symbol.description;
// assert
expect(expected).to.equal(actual);
});
});
describe('injectKey', () => {
it('returns the correct value for singleton keys', () => {
// arrange
const mockValue = { someProperty: 'someValue' };
// act
const mockInject = () => mockValue;
const result = injectKey((keys) => keys.useApplication, mockInject);
// assert
expect(result).to.equal(mockValue);
});
it('invokes and returns result from factory function for transient keys', () => {
// arrange
const mockValue = { anotherProperty: 'anotherValue' };
const mockFactory = () => mockValue;
const mockInject = () => mockFactory;
// act
const result = injectKey((keys) => keys.useCollectionState, mockInject);
// assert
expect(result).to.equal(mockValue);
});
describe('throws error when no value is provided for a key', () => {
// arrange
const testScenarios: ReadonlyArray<{
readonly name: string,
readonly act: (selector: InjectionKeySelector<unknown>) => void,
}> = getAbsentObjectTestCases().flatMap((absentTest) => [
{
name: `singleton factory: ${absentTest.absentValue}`,
act: (selector) => injectKey(selector, () => (() => absentTest.absentValue)),
},
{
name: `transient: ${absentTest.absentValue}`,
act: (selector) => injectKey(selector, () => absentTest.absentValue),
},
]);
testScenarios.forEach((testCase) => {
it(testCase.name, () => {
// arrange
const key = InjectionKeys.useRuntimeEnvironment;
const expectedError = `Failed to inject value for key: ${key.key.description}`;
const selector: InjectionKeySelector<typeof key> = () => key;
// act
const act = () => testCase.act(selector);
// assert
expect(act).to.throw(expectedError);
});
});
});
});