diff --git a/.ncurc.json b/.ncurc.json index f8d203c5..dada7064 100644 --- a/.ncurc.json +++ b/.ncurc.json @@ -1,3 +1,3 @@ { - "reject": ["chalk", "execa"] + "reject": ["chalk", "execa", "emittery"] } diff --git a/nyc.config.js b/nyc.config.js index 3c1553d7..0117af5e 100644 --- a/nyc.config.js +++ b/nyc.config.js @@ -3,5 +3,5 @@ module.exports = { all: true, reporter: ['html', 'lcov', 'json', 'json-summary', 'text-summary'], 'report-dir': 'coverage', - exclude: ['**/*.test.ts', '**/test-support/**/*', '**/test-support.ts', '**/coverage/**', '**/lib/**', '**/types/**'], + exclude: ['**/*.test.ts', '**/coverage/**', '**/lib/**', '**/types/**'], }; diff --git a/package.json b/package.json index 2a18f13c..4e73d3c8 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "clean:packages": "lerna run clean", "dependency:upgrade": "ncu --upgrade --peer --deep && yarn-deduplicate --strategy fewer && yarn install", "lint": "npm-run-all --aggregate-output --continue-on-error --parallel 'lint:!(fix)'", - "lint:code": "eslint --cache --max-warnings 0 .", + "lint:code": "eslint --cache .", "lint:code:fix": "eslint --fix .", "lint:style": "prettier --check .", "lint:style:fix": "prettier --write .", @@ -28,7 +28,7 @@ } }, "devDependencies": { - "@commitlint/cli": "^17.4.4", + "@commitlint/cli": "^17.5.0", "@commitlint/config-conventional": "^17.4.4", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@johanblumenberg/ts-mockito": "^1.0.35", @@ -38,22 +38,23 @@ "@types/debug": "^4.1.7", "@types/mocha": "^10.0.1", "@types/mock-fs": "^4.13.1", - "@types/node": "^18.14.2", + "@types/node": "^18.15.5", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "husky": "^8.0.3", "json": "^11.0.0", - "lerna": "^6.5.1", - "lint-staged": ">=13.1.2", + "lerna": "^6.6.0", + "lint-staged": ">=13.2.0", "mocha": "^10.2.0", - "npm-check-updates": "^16.7.9", + "npm-check-updates": "^16.8.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "prettier": "2.8.4", + "prettier": "2.8.6", "ts-node": "^10.9.1", - "typedoc": "^0.23.25", + "tsconfig-paths": "^4.1.2", + "typedoc": "^0.23.28", "typedoc-plugin-resolve-crossmodule-references": "^0.3.3", "typescript": "^4.9.5", "yarn-deduplicate": "^6.0.1" diff --git a/packages/adapter-tcp/package.json b/packages/adapter-tcp/package.json index da7da00c..bf1e7b57 100644 --- a/packages/adapter-tcp/package.json +++ b/packages/adapter-tcp/package.json @@ -36,7 +36,7 @@ "build:lib": "tsc -b tsconfig.build.json", "clean": "rm -rf .build-cache lib coverage", "posttest": "test \"$(cat coverage/coverage-summary.json | json total.lines.total)\" -gt 0", - "test": "nyc mocha src" + "test": "nyc mocha src test" }, "nx": { "targets": { @@ -54,10 +54,14 @@ ] }, "test": { + "dependsOn": [ + "build" + ], "inputs": [ "{workspaceRoot}/.mocharc.yml", "{workspaceRoot}/nyc.config.js", - "{projectRoot}/src/**/*" + "{projectRoot}/src/**/*", + "{projectRoot}/test/**/*" ], "outputs": [ "{projectRoot}/coverage" @@ -68,9 +72,9 @@ "dependencies": { "@agnoc/domain": "^0.18.0-next.0", "@agnoc/schemas-tcp": "^0.16.0", + "@agnoc/transport-tcp": "^0.18.0-next.0", "@agnoc/toolkit": "^0.18.0-next.0", - "debug": "^4.3.4", - "tiny-typed-emitter": "^2.1.0", + "emittery": "^0.13.1", "tslib": "^2.5.0" }, "devDependencies": { @@ -83,6 +87,6 @@ "typedoc": { "entryPoint": "./src/index.ts", "readmeFile": "./README.md", - "displayName": "adapter TCP" + "displayName": "Adapter TCP" } } diff --git a/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.test.ts b/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.test.ts new file mode 100644 index 00000000..7ee6b634 --- /dev/null +++ b/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.test.ts @@ -0,0 +1,316 @@ +import { Socket, Server } from 'net'; +import { Connection, Device } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { ArgumentInvalidException, ArgumentNotProvidedException, DomainException, ID } from '@agnoc/toolkit'; +import { Packet, PacketSocket } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, defer, imock, instance, spy, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { PacketConnection } from './packet-connection.aggregate-root'; +import type { PacketEventBus } from '../event-buses/packet.event-bus'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketFactory, PacketMapper } from '@agnoc/transport-tcp'; +import type { AddressInfo } from 'net'; + +describe('PacketConnection', function () { + let packetFactory: PacketFactory; + let eventBus: PacketEventBus; + let packetMapper: PacketMapper; + let packetSocket: PacketSocket; + let packetSocketSpy: PacketSocket; + let packetMessage: PacketMessage; + let socket: Socket; + let server: Server; + + beforeEach(function () { + server = new Server(); + socket = new Socket(); + packetFactory = imock(); + eventBus = imock(); + packetMapper = imock(); + packetMessage = imock(); + packetSocket = new PacketSocket(instance(packetMapper), socket); + packetSocketSpy = spy(packetSocket); + }); + + it('should be created', function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + + expect(connection).to.be.instanceOf(Connection); + expect(connection.id).to.be.equal(props.id); + expect(connection.socket).to.be.equal(props.socket); + expect(connection.connectionType).to.be.equal('PACKET'); + expect(PacketConnection.isPacketConnection(connection)).to.be.true; + }); + + it("should throw an error when 'socket' is not provided", function () { + expect( + // @ts-expect-error - invalid property + () => new PacketConnection(instance(packetFactory), instance(eventBus), { id: ID.generate(), socket: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'socket' for PacketConnection not provided`); + }); + + it("should throw an error when 'socket' is not a PacketSocket", function () { + expect( + // @ts-expect-error - invalid property + () => new PacketConnection(instance(packetFactory), instance(eventBus), { id: ID.generate(), socket: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'socket' of PacketConnection is not an instance of PacketSocket`, + ); + }); + + describe('#send()', function () { + it('should do nothing', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + + await expect(connection.send('DEVICE_GETTIME_RSP', data)).to.be.rejectedWith( + DomainException, + 'Unable to send packet through a closed connection', + ); + + verify(packetFactory.create(anything(), anything(), anything())).never(); + verify(packetSocketSpy.write(anything())).never(); + }); + }); + + describe('#respond()', function () { + it('should do nothing', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const anotherPacket = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(anotherPacket); + + await expect(connection.respond('DEVICE_GETTIME_RSP', data, packet)).to.be.rejectedWith( + DomainException, + 'Unable to send packet through a closed connection', + ); + + verify(packetFactory.create(anything(), anything(), anything())).never(); + verify(packetSocketSpy.write(anything())).never(); + }); + }); + + describe('#sendAndWait()', function () { + it('should do nothing', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + + await expect(connection.sendAndWait('DEVICE_GETTIME_RSP', data)).to.be.rejectedWith( + DomainException, + 'Unable to send packet through a closed connection', + ); + + verify(packetFactory.create(anything(), anything(), anything())).never(); + verify(packetSocketSpy.write(anything())).never(); + }); + }); + + describe('#respondAndWait()', function () { + it('should do nothing', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + + await expect(connection.respondAndWait('DEVICE_GETTIME_RSP', data, packet)).to.be.rejectedWith( + DomainException, + 'Unable to send packet through a closed connection', + ); + + verify(packetFactory.create(anything(), anything(), anything())).never(); + verify(packetSocketSpy.write(anything())).never(); + }); + }); + + describe('#close()', function () { + it('should do nothing', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + + await connection.close(); + + verify(packetSocketSpy.end()).never(); + }); + }); + + describe('with socket connected', function () { + beforeEach(function (done) { + server.listen(0, () => { + socket.connect((server.address() as AddressInfo).port, done); + }); + }); + + afterEach(function (done) { + if (socket.readyState === 'open') { + socket.end(); + } + + if (server.listening) { + server.close(done); + } else { + done(); + } + }); + + describe('#send()', function () { + it('should send a packet', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + + await connection.send('DEVICE_GETTIME_RSP', data); + + verify( + packetFactory.create('DEVICE_GETTIME_RSP', data, deepEqual({ deviceId: new ID(0), userId: new ID(0) })), + ).once(); + verify(packetSocketSpy.write(packet)).once(); + }); + + it('should send a packet with a device attached', async function () { + const device = new Device(givenSomeDeviceProps()); + const props = { id: ID.generate(), socket: packetSocket, device }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + + await connection.send('DEVICE_GETTIME_RSP', data); + + verify( + packetFactory.create('DEVICE_GETTIME_RSP', data, deepEqual({ deviceId: device.id, userId: device.userId })), + ).once(); + verify(packetSocketSpy.write(packet)).once(); + }); + }); + + describe('#respond()', function () { + it('should respond with a packet', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const anotherPacket = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(anotherPacket); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + + await connection.respond('DEVICE_GETTIME_RSP', data, packet); + + verify(packetFactory.create('DEVICE_GETTIME_RSP', data, packet)).once(); + verify(packetSocketSpy.write(anotherPacket)).once(); + }); + }); + + describe('#sendAndWait()', function () { + it('should send a packet and wait for the response', function (done) { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + const eventPromise = defer(); + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + when(eventBus.once(anything())).thenReturn(eventPromise); + + void connection.sendAndWait('DEVICE_GETTIME_RSP', data).then((ret) => { + expect(ret).to.be.equal(instance(packetMessage)); + + verify( + packetFactory.create('DEVICE_GETTIME_RSP', data, deepEqual({ deviceId: new ID(0), userId: new ID(0) })), + ).once(); + verify(packetSocketSpy.write(packet)).once(); + verify(eventBus.once(packet.sequence.toString())).once(); + done(); + }); + + void eventPromise.resolve(instance(packetMessage)); + }); + + it('should send a packet with a device attached and wait for the response', function (done) { + const device = new Device(givenSomeDeviceProps()); + const props = { id: ID.generate(), socket: packetSocket, device }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + const eventPromise = defer(); + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + when(eventBus.once(anything())).thenReturn(eventPromise); + + void connection.sendAndWait('DEVICE_GETTIME_RSP', data).then((ret) => { + expect(ret).to.be.equal(instance(packetMessage)); + + verify( + packetFactory.create('DEVICE_GETTIME_RSP', data, deepEqual({ deviceId: device.id, userId: device.userId })), + ).once(); + verify(packetSocketSpy.write(packet)).once(); + verify(eventBus.once(packet.sequence.toString())).once(); + done(); + }); + + void eventPromise.resolve(instance(packetMessage)); + }); + }); + + describe('#respondAndWait()', function () { + it('should respond to a packet and wait for the response', function (done) { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + const packet = new Packet(givenSomePacketProps()); + const anotherPacket = new Packet(givenSomePacketProps()); + const data = { result: 0, body: { deviceTime: 1 } }; + const eventPromise = defer(); + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(anotherPacket); + when(packetMapper.fromDomain(anything())).thenReturn(Buffer.alloc(0)); + when(eventBus.once(anything())).thenReturn(eventPromise); + + void connection.respondAndWait('DEVICE_GETTIME_RSP', data, packet).then((ret) => { + expect(ret).to.be.equal(instance(packetMessage)); + + verify(packetFactory.create('DEVICE_GETTIME_RSP', data, packet)).once(); + verify(packetSocketSpy.write(anotherPacket)).once(); + verify(eventBus.once(packet.sequence.toString())).once(); + done(); + }); + + void eventPromise.resolve(instance(packetMessage)); + }); + }); + + describe('#close()', function () { + it('should close the socket', async function () { + const props = { id: ID.generate(), socket: packetSocket }; + const connection = new PacketConnection(instance(packetFactory), instance(eventBus), props); + + await connection.close(); + + verify(packetSocketSpy.end()).once(); + }); + }); + }); +}); diff --git a/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.ts b/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.ts new file mode 100644 index 00000000..aa953bbf --- /dev/null +++ b/packages/adapter-tcp/src/aggregate-roots/packet-connection.aggregate-root.ts @@ -0,0 +1,94 @@ +import { Connection } from '@agnoc/domain'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { PacketSocket } from '@agnoc/transport-tcp'; +import type { PacketEventBus } from '../event-buses/packet.event-bus'; +import type { PacketMessage } from '../objects/packet.message'; +import type { ConnectionProps } from '@agnoc/domain'; +import type { Packet, PacketFactory, PayloadDataName, PayloadDataFrom, CreatePacketProps } from '@agnoc/transport-tcp'; + +export interface PacketConnectionProps extends ConnectionProps { + socket: PacketSocket; +} + +export class PacketConnection extends Connection { + readonly connectionType = 'PACKET'; + + constructor( + private readonly packetFactory: PacketFactory, + private readonly eventBus: PacketEventBus, + props: PacketConnectionProps, + ) { + super(props); + } + + get socket(): PacketSocket { + return this.props.socket; + } + + async send(name: Name, data: PayloadDataFrom): Promise { + this.validateConnectedSocket(); + + const packet = this.packetFactory.create(name, data, this.getPacketProps()); + + return this.socket.write(packet); + } + + async respond(name: Name, data: PayloadDataFrom, packet: Packet): Promise { + this.validateConnectedSocket(); + + return this.socket.write(this.packetFactory.create(name, data, packet)); + } + + async sendAndWait(name: Name, data: PayloadDataFrom): Promise { + this.validateConnectedSocket(); + + const packet = this.packetFactory.create(name, data, this.getPacketProps()); + + return this.writeAndWait(packet); + } + + async respondAndWait( + name: Name, + data: PayloadDataFrom, + packet: Packet, + ): Promise { + this.validateConnectedSocket(); + + return this.writeAndWait(this.packetFactory.create(name, data, packet)); + } + + async close(): Promise { + if (!this.socket.connected) { + return; + } + + return this.socket.end(); + } + + protected override validate(props: PacketConnectionProps): void { + super.validate(props); + this.validateDefinedProp(props, 'socket'); + this.validateInstanceProp(props, 'socket', PacketSocket); + } + + private getPacketProps(): CreatePacketProps { + return { deviceId: this.device?.id ?? new ID(0), userId: this.device?.userId ?? new ID(0) }; + } + + private writeAndWait(packet: Packet): Promise { + return new Promise((resolve, reject) => { + this.eventBus.once(packet.sequence.toString()).then(resolve, reject); + this.socket.write(packet).catch(reject); + }); + } + + private validateConnectedSocket(): void { + if (!this.socket.connected) { + throw new DomainException('Unable to send packet through a closed connection'); + } + } + + static isPacketConnection(connection: Connection): connection is PacketConnection { + return connection.connectionType === 'PACKET'; + } +} diff --git a/packages/adapter-tcp/src/base-classes/packet.event-handler.ts b/packages/adapter-tcp/src/base-classes/packet.event-handler.ts new file mode 100644 index 00000000..67f20e33 --- /dev/null +++ b/packages/adapter-tcp/src/base-classes/packet.event-handler.ts @@ -0,0 +1,12 @@ +import type { PacketMessage } from '../objects/packet.message'; +import type { EventHandler } from '@agnoc/toolkit'; +import type { PayloadDataName } from '@agnoc/transport-tcp'; + +/** Base class for packet event handlers. */ +export abstract class PacketEventHandler implements EventHandler { + /** The name of the event to listen to. */ + abstract forName: PayloadDataName; + + /** Handle the event. */ + abstract handle(message: PacketMessage): void; +} diff --git a/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.test.ts new file mode 100644 index 00000000..b4b24bbe --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.test.ts @@ -0,0 +1,70 @@ +import { CleanSpotCommand, DeviceMode, DeviceModeValue, MapCoordinate, MapPosition } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import { CleanSpotCommandHandler } from './clean-spot.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('CleanSpotCommandHandler', function () { + let deviceModeChangerService: DeviceModeChangerService; + let deviceCleaningService: DeviceCleaningService; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: CleanSpotCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + + beforeEach(function () { + deviceModeChangerService = imock(); + deviceCleaningService = imock(); + packetConnectionFinderService = imock(); + commandHandler = new CleanSpotCommandHandler( + instance(packetConnectionFinderService), + instance(deviceModeChangerService), + instance(deviceCleaningService), + ); + packetConnection = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('CleanSpotCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const command = new CleanSpotCommand({ deviceId: new ID(1), spot: new MapCoordinate({ x: 1, y: 2 }) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceModeChangerService.changeMode(anything(), anything())).never(); + verify(deviceCleaningService.spotCleaning(anything(), anything(), anything())).never(); + }); + + it('should clean a spot', async function () { + const command = new CleanSpotCommand({ deviceId: new ID(1), spot: new MapCoordinate({ x: 1, y: 2 }) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.Spot)), + ), + ).once(); + verify( + deviceCleaningService.spotCleaning( + instance(packetConnection), + deepEqual(new MapPosition({ x: 1, y: 2, phi: 0 })), + ModeCtrlValue.Start, + ), + ).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.ts b/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.ts new file mode 100644 index 00000000..8075cd58 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/clean-spot.command-handler.ts @@ -0,0 +1,29 @@ +import { DeviceMode, DeviceModeValue, MapPosition } from '@agnoc/domain'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, CleanSpotCommand } from '@agnoc/domain'; + +export class CleanSpotCommandHandler implements CommandHandler { + readonly forName = 'CleanSpotCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceModeChangerService: DeviceModeChangerService, + private readonly deviceCleaningService: DeviceCleaningService, + ) {} + + async handle(event: CleanSpotCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + const { x, y } = event.spot; + + await this.deviceModeChangerService.changeMode(connection, new DeviceMode(DeviceModeValue.Spot)); + await this.deviceCleaningService.spotCleaning(connection, new MapPosition({ x, y, phi: 0 }), ModeCtrlValue.Start); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.test.ts new file mode 100644 index 00000000..c4a914e9 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.test.ts @@ -0,0 +1,100 @@ +import { CleanZonesCommand, DeviceMode, DeviceModeValue, MapCoordinate, Zone } from '@agnoc/domain'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import { CleanZonesCommandHandler } from './clean-zones.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceMapService } from '../services/device-map.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, Device, DeviceMap } from '@agnoc/domain'; + +describe('CleanZonesCommandHandler', function () { + let deviceMapService: DeviceMapService; + let deviceModeChangerService: DeviceModeChangerService; + let deviceCleaningService: DeviceCleaningService; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: CleanZonesCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let device: Device; + let deviceMap: DeviceMap; + + beforeEach(function () { + deviceMapService = imock(); + deviceModeChangerService = imock(); + deviceCleaningService = imock(); + packetConnectionFinderService = imock(); + commandHandler = new CleanZonesCommandHandler( + instance(packetConnectionFinderService), + instance(deviceModeChangerService), + instance(deviceMapService), + instance(deviceCleaningService), + ); + packetConnection = imock(); + device = imock(); + deviceMap = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('CleanZonesCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const zone = new Zone({ id: new ID(1), coordinates: [new MapCoordinate({ x: 1, y: 2 })] }); + const command = new CleanZonesCommand({ deviceId: new ID(1), zones: [zone] }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceModeChangerService.changeMode(anything(), anything())).never(); + verify(deviceMapService.setMapZones(anything(), anything())).never(); + verify(deviceCleaningService.zoneCleaning(anything(), anything())).never(); + }); + + it('should clean zones', async function () { + const zone = new Zone({ id: new ID(1), coordinates: [new MapCoordinate({ x: 1, y: 2 })] }); + const command = new CleanZonesCommand({ deviceId: new ID(1), zones: [zone] }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device) as DeviceWithMap); + when(device.map).thenReturn(instance(deviceMap)); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.Zone)), + ), + ).once(); + verify(deviceMapService.setMapZones(instance(packetConnection), deepEqual([zone]))).once(); + verify(deviceCleaningService.zoneCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should throw an error when device has no map', async function () { + const zone = new Zone({ id: new ID(1), coordinates: [new MapCoordinate({ x: 1, y: 2 })] }); + const command = new CleanZonesCommand({ deviceId: new ID(1), zones: [zone] }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device) as DeviceWithMap); + when(device.map).thenReturn(undefined); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to clean zones without a device with a map', + ); + + verify(deviceModeChangerService.changeMode(anything(), anything())).never(); + verify(deviceMapService.setMapZones(anything(), anything())).never(); + verify(deviceCleaningService.zoneCleaning(anything(), anything())).never(); + }); + }); +}); + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.ts b/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.ts new file mode 100644 index 00000000..22531cf3 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/clean-zones.command-handler.ts @@ -0,0 +1,46 @@ +import { DeviceMode, DeviceModeValue } from '@agnoc/domain'; +import { DomainException } from '@agnoc/toolkit'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceMapService } from '../services/device-map.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, CleanZonesCommand, ConnectionWithDevice, Device, DeviceMap } from '@agnoc/domain'; + +export class CleanZonesCommandHandler implements CommandHandler { + readonly forName = 'CleanZonesCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceModeChangerService: DeviceModeChangerService, + private readonly deviceMapService: DeviceMapService, + private readonly deviceCleaningService: DeviceCleaningService, + ) {} + + async handle(event: CleanZonesCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + this.assertDeviceMap(connection); + + await this.deviceModeChangerService.changeMode(connection, new DeviceMode(DeviceModeValue.Zone)); + await this.deviceMapService.setMapZones(connection, event.zones); + await this.deviceCleaningService.zoneCleaning(connection, ModeCtrlValue.Start); + } + + assertDeviceMap( + connection: PacketConnection & ConnectionWithDevice, + ): asserts connection is PacketConnection & ConnectionWithDevice { + if (!connection.device.map) { + throw new DomainException('Unable to clean zones without a device with a map'); + } + } +} + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.test.ts new file mode 100644 index 00000000..e3e43f26 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.test.ts @@ -0,0 +1,52 @@ +import { LocateDeviceCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { LocateDeviceCommandHandler } from './locate-device.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('LocateDeviceCommandHandler', function () { + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: LocateDeviceCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + + beforeEach(function () { + packetConnectionFinderService = imock(); + commandHandler = new LocateDeviceCommandHandler(instance(packetConnectionFinderService)); + packetConnection = imock(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('LocateDeviceCommand'); + }); + + describe('#handle()', function () { + it('should send locate device command', async function () { + const command = new LocateDeviceCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_SEEK_LOCATION_REQ', deepEqual({}))).once(); + verify(packetMessage.assertPayloadName('DEVICE_SEEK_LOCATION_RSP')).once(); + }); + + it('should do nothing when no connection is found', async function () { + const command = new LocateDeviceCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.ts b/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.ts new file mode 100644 index 00000000..21ef246d --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/locate-device.command-handler.ts @@ -0,0 +1,21 @@ +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, LocateDeviceCommand } from '@agnoc/domain'; + +export class LocateDeviceCommandHandler implements CommandHandler { + readonly forName = 'LocateDeviceCommand'; + + constructor(private readonly packetConnectionFinderService: PacketConnectionFinderService) {} + + async handle(event: LocateDeviceCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + const response: PacketMessage = await connection.sendAndWait('DEVICE_SEEK_LOCATION_REQ', {}); + + response.assertPayloadName('DEVICE_SEEK_LOCATION_RSP'); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.test.ts new file mode 100644 index 00000000..7fe4226d --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.test.ts @@ -0,0 +1,133 @@ +import { DeviceMap, DeviceMode, DeviceModeValue, MapPosition, PauseCleaningCommand } from '@agnoc/domain'; +import { givenSomeDeviceMapProps } from '@agnoc/domain/test-support'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import { PauseCleaningCommandHandler } from './pause-cleaning.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, Device } from '@agnoc/domain'; + +describe('PauseCleaningCommandHandler', function () { + let deviceCleaningService: DeviceCleaningService; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: PauseCleaningCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let device: Device; + + beforeEach(function () { + deviceCleaningService = imock(); + packetConnectionFinderService = imock(); + commandHandler = new PauseCleaningCommandHandler( + instance(packetConnectionFinderService), + instance(deviceCleaningService), + ); + packetConnection = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('PauseCleaningCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceCleaningService.autoCleaning(anything(), anything())).never(); + }); + + it('should pause auto cleaning', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(undefined); + + await commandHandler.handle(command); + + verify(deviceCleaningService.autoCleaning(instance(packetConnection), ModeCtrlValue.Pause)); + }); + + it('should pause zone cleaning', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Zone)); + + await commandHandler.handle(command); + + verify(deviceCleaningService.zoneCleaning(instance(packetConnection), ModeCtrlValue.Pause)); + }); + + it('should pause mop cleaning', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + + await commandHandler.handle(command); + + verify(deviceCleaningService.mopCleaning(instance(packetConnection), ModeCtrlValue.Pause)); + }); + + it('should pause spot cleaning', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + const currentSpot = new MapPosition({ x: 0, y: 0, phi: 0 }); + const deviceMap = new DeviceMap({ + ...givenSomeDeviceMapProps(), + id: new ID(1), + currentSpot, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.map).thenReturn(deviceMap); + + await commandHandler.handle(command); + + verify(deviceCleaningService.spotCleaning(instance(packetConnection), currentSpot, ModeCtrlValue.Pause)); + }); + + it('should throw an error when trying to spot clean without map', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.map).thenReturn(undefined); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to pause spot cleaning, no map available', + ); + }); + + it('should throw an error when trying to spot clean without current spot', async function () { + const command = new PauseCleaningCommand({ deviceId: new ID(1) }); + const deviceMap = new DeviceMap({ + ...givenSomeDeviceMapProps(), + currentSpot: undefined, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.map).thenReturn(deviceMap); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to pause spot cleaning, no spot selected', + ); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.ts b/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.ts new file mode 100644 index 00000000..df52ab80 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/pause-cleaning.command-handler.ts @@ -0,0 +1,53 @@ +import { DeviceModeValue } from '@agnoc/domain'; +import { DomainException } from '@agnoc/toolkit'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, PauseCleaningCommand, ConnectionWithDevice } from '@agnoc/domain'; + +export class PauseCleaningCommandHandler implements CommandHandler { + readonly forName = 'PauseCleaningCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceCleaningService: DeviceCleaningService, + ) {} + + async handle(event: PauseCleaningCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + const device = connection.device; + const deviceModeValue = device.mode?.value; + + if (deviceModeValue === DeviceModeValue.Zone) { + return this.deviceCleaningService.zoneCleaning(connection, ModeCtrlValue.Pause); + } + + if (deviceModeValue === DeviceModeValue.Mop) { + return this.deviceCleaningService.mopCleaning(connection, ModeCtrlValue.Pause); + } + + if (deviceModeValue === DeviceModeValue.Spot) { + return this.pauseSpotCleaning(connection); + } + + return this.deviceCleaningService.autoCleaning(connection, ModeCtrlValue.Pause); + } + + private async pauseSpotCleaning(connection: PacketConnection & ConnectionWithDevice) { + if (!connection.device.map) { + throw new DomainException('Unable to pause spot cleaning, no map available'); + } + + if (!connection.device.map.currentSpot) { + throw new DomainException('Unable to pause spot cleaning, no spot selected'); + } + + await this.deviceCleaningService.spotCleaning(connection, connection.device.map.currentSpot, ModeCtrlValue.Pause); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.test.ts new file mode 100644 index 00000000..84992d82 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.test.ts @@ -0,0 +1,102 @@ +import { DeviceConsumable, DeviceConsumableType, ResetConsumableCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ResetConsumableCommandHandler } from './reset-consumable.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('ResetConsumableCommandHandler', function () { + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: ResetConsumableCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + packetConnectionFinderService = imock(); + commandHandler = new ResetConsumableCommandHandler( + instance(packetConnectionFinderService), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('ResetConsumableCommand'); + }); + + describe('#handle()', function () { + it('should reset an existing consumable', async function () { + const mainBrush = new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 100 }); + const sideBrush = new DeviceConsumable({ type: DeviceConsumableType.SideBrush, hoursUsed: 50 }); + const command = new ResetConsumableCommand({ + deviceId: new ID(1), + consumable: mainBrush, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.consumables).thenReturn([mainBrush, sideBrush]); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_MAPID_SET_CONSUMABLES_PARAM_REQ', deepEqual({ itemId: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_CONSUMABLES_PARAM_RSP')).once(); + verify( + device.updateConsumables( + deepEqual([new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 0 }), sideBrush]), + ), + ).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should reset a non existing consumable', async function () { + const mainBrush = new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 100 }); + const command = new ResetConsumableCommand({ + deviceId: new ID(1), + consumable: mainBrush, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.consumables).thenReturn(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_MAPID_SET_CONSUMABLES_PARAM_REQ', deepEqual({ itemId: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_CONSUMABLES_PARAM_RSP')).once(); + verify( + device.updateConsumables( + deepEqual([new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 0 })]), + ), + ).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const consumable = new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 100 }); + const command = new ResetConsumableCommand({ + deviceId: new ID(1), + consumable, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.ts b/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.ts new file mode 100644 index 00000000..77b4ea6f --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/reset-consumable.command-handler.ts @@ -0,0 +1,70 @@ +import { DeviceConsumable, DeviceConsumableType } from '@agnoc/domain'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { + CommandHandler, + ResetConsumableCommand, + DeviceRepository, + ConnectionWithDevice, + Device, +} from '@agnoc/domain'; + +export class ResetConsumableCommandHandler implements CommandHandler { + readonly forName = 'ResetConsumableCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: ResetConsumableCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + await this.resetConsumable(connection, event); + await this.updateDeviceConsumables(connection, event); + } + + private async resetConsumable( + connection: PacketConnection & ConnectionWithDevice, + event: ResetConsumableCommand, + ) { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_SET_CONSUMABLES_PARAM_REQ', { + itemId: DeviceConsumableItemId[event.consumable.type], + }); + + response.assertPayloadName('DEVICE_MAPID_SET_CONSUMABLES_PARAM_RSP'); + } + + private async updateDeviceConsumables( + connection: PacketConnection & ConnectionWithDevice, + event: ResetConsumableCommand, + ) { + const resetConsumable = new DeviceConsumable({ + type: event.consumable.type, + hoursUsed: 0, + }); + const consumables = connection.device.consumables?.map((consumable) => { + if (consumable.type === event.consumable.type) { + return resetConsumable; + } + + return consumable; + }); + + connection.device.updateConsumables(consumables ?? [resetConsumable]); + + await this.deviceRepository.saveOne(connection.device); + } +} + +const DeviceConsumableItemId = { + [DeviceConsumableType.MainBrush]: 1, + [DeviceConsumableType.SideBrush]: 2, + [DeviceConsumableType.Filter]: 3, + [DeviceConsumableType.Dishcloth]: 4, +}; diff --git a/packages/adapter-tcp/src/command-handlers/return-home.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/return-home.command-handler.test.ts new file mode 100644 index 00000000..0fba4c9f --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/return-home.command-handler.test.ts @@ -0,0 +1,52 @@ +import { ReturnHomeCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ReturnHomeCommandHandler } from './return-home.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('ReturnHomeCommandHandler', function () { + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: ReturnHomeCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + + beforeEach(function () { + packetConnectionFinderService = imock(); + commandHandler = new ReturnHomeCommandHandler(instance(packetConnectionFinderService)); + packetConnection = imock(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('ReturnHomeCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const command = new ReturnHomeCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + }); + + it('should stop cleaning', async function () { + const command = new ReturnHomeCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_CHARGE_REQ', deepEqual({ enable: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_CHARGE_RSP')).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/return-home.command-handler.ts b/packages/adapter-tcp/src/command-handlers/return-home.command-handler.ts new file mode 100644 index 00000000..097d0e16 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/return-home.command-handler.ts @@ -0,0 +1,23 @@ +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, ReturnHomeCommand } from '@agnoc/domain'; + +export class ReturnHomeCommandHandler implements CommandHandler { + readonly forName = 'ReturnHomeCommand'; + + constructor(private readonly packetConnectionFinderService: PacketConnectionFinderService) {} + + async handle(event: ReturnHomeCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + const response: PacketMessage = await connection.sendAndWait('DEVICE_CHARGE_REQ', { + enable: 1, + }); + + response.assertPayloadName('DEVICE_CHARGE_RSP'); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.test.ts new file mode 100644 index 00000000..ae2ac1e0 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.test.ts @@ -0,0 +1,92 @@ +import { DeviceSetting, DeviceSettings, SetCarpetModeCommand } from '@agnoc/domain'; +import { givenSomeDeviceSettingsProps } from '@agnoc/domain/test-support'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetCarpetModeCommandHandler } from './set-carpet-mode.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('SetCarpetModeCommandHandler', function () { + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetCarpetModeCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + packetConnectionFinderService = imock(); + commandHandler = new SetCarpetModeCommandHandler( + instance(packetConnectionFinderService), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetCarpetModeCommand'); + }); + + describe('#handle()', function () { + it('should change and set carpet mode', async function () { + const carpetMode = new DeviceSetting({ isEnabled: true }); + const deviceSettings = new DeviceSettings(givenSomeDeviceSettingsProps()); + const command = new SetCarpetModeCommand({ deviceId: new ID(1), carpetMode }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.settings).thenReturn(deviceSettings); + + await commandHandler.handle(command); + + verify( + packetConnection.sendAndWait('USER_SET_DEVICE_CLEANPREFERENCE_REQ', deepEqual({ carpetTurbo: true })), + ).once(); + verify(packetMessage.assertPayloadName('USER_SET_DEVICE_CLEANPREFERENCE_RSP')).once(); + verify( + device.updateSettings(deepEqual(new DeviceSettings({ ...givenSomeDeviceSettingsProps(), carpetMode }))), + ).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const carpetMode = new DeviceSetting({ isEnabled: true }); + const command = new SetCarpetModeCommand({ deviceId: new ID(1), carpetMode }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + + it('should throw an error when device has no settings', async function () { + const carpetMode = new DeviceSetting({ isEnabled: true }); + const command = new SetCarpetModeCommand({ deviceId: new ID(1), carpetMode }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.settings).thenReturn(undefined); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to set voice setting when device settings are not available', + ); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.ts new file mode 100644 index 00000000..48276cbd --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-carpet-mode.command-handler.ts @@ -0,0 +1,68 @@ +import { DomainException } from '@agnoc/toolkit'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { + CommandHandler, + ConnectionWithDevice, + Device, + DeviceRepository, + DeviceSettings, + SetCarpetModeCommand, +} from '@agnoc/domain'; + +export class SetCarpetModeCommandHandler implements CommandHandler { + readonly forName = 'SetCarpetModeCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: SetCarpetModeCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + this.assertDeviceSettings(connection); + + await this.sendCarpetMode(event, connection); + await this.updateDeviceSettings(event, connection); + } + + private assertDeviceSettings( + connection: PacketConnection & ConnectionWithDevice, + ): asserts connection is PacketConnection & ConnectionWithDevice { + if (!connection.device.settings) { + throw new DomainException('Unable to set voice setting when device settings are not available'); + } + } + + private async updateDeviceSettings( + { carpetMode }: SetCarpetModeCommand, + connection: PacketConnection & ConnectionWithDevice, + ) { + const settings = connection.device.settings.clone({ carpetMode }); + + connection.device.updateSettings(settings); + + await this.deviceRepository.saveOne(connection.device); + } + + private async sendCarpetMode( + { carpetMode }: SetCarpetModeCommand, + connection: PacketConnection & ConnectionWithDevice, + ) { + const response: PacketMessage = await connection.sendAndWait('USER_SET_DEVICE_CLEANPREFERENCE_REQ', { + carpetTurbo: carpetMode.isEnabled, + }); + + response.assertPayloadName('USER_SET_DEVICE_CLEANPREFERENCE_RSP'); + } +} + +interface DeviceWithSettings extends Device { + settings: DeviceSettings; +} diff --git a/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.test.ts new file mode 100644 index 00000000..77821b30 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.test.ts @@ -0,0 +1,54 @@ +import { DeviceMode, DeviceModeValue, SetDeviceModeCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetDeviceModeCommandHandler } from './set-device-mode.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('SetDeviceModeCommandHandler', function () { + let deviceModeChangerService: DeviceModeChangerService; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetDeviceModeCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + + beforeEach(function () { + deviceModeChangerService = imock(); + packetConnectionFinderService = imock(); + commandHandler = new SetDeviceModeCommandHandler( + instance(packetConnectionFinderService), + instance(deviceModeChangerService), + ); + packetConnection = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetDeviceModeCommand'); + }); + + describe('#handle()', function () { + it('should change and set device mode', async function () { + const mode = new DeviceMode(DeviceModeValue.Mop); + const command = new SetDeviceModeCommand({ deviceId: new ID(1), mode }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + + await commandHandler.handle(command); + + verify(deviceModeChangerService.changeMode(instance(packetConnection), mode)).once(); + }); + + it('should do nothing when no connection is found', async function () { + const mode = new DeviceMode(DeviceModeValue.Mop); + const command = new SetDeviceModeCommand({ deviceId: new ID(1), mode }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceModeChangerService.changeMode(anything(), anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.ts new file mode 100644 index 00000000..63b2b3fc --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-mode.command-handler.ts @@ -0,0 +1,22 @@ +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, SetDeviceModeCommand } from '@agnoc/domain'; + +export class SetDeviceModeCommandHandler implements CommandHandler { + readonly forName = 'SetDeviceModeCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceModeChangerService: DeviceModeChangerService, + ) {} + + async handle(event: SetDeviceModeCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + await this.deviceModeChangerService.changeMode(connection, event.mode); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.test.ts new file mode 100644 index 00000000..d27c4b50 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.test.ts @@ -0,0 +1,72 @@ +import { DeviceTime, QuietHoursSetting, SetDeviceQuietHoursCommand } from '@agnoc/domain'; +import { givenSomeQuietHoursSettingProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetDeviceQuietHoursCommandHandler } from './set-device-quiet-hours.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('SetDeviceQuietHoursCommandHandler', function () { + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetDeviceQuietHoursCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + + beforeEach(function () { + packetConnectionFinderService = imock(); + commandHandler = new SetDeviceQuietHoursCommandHandler(instance(packetConnectionFinderService)); + packetConnection = imock(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetDeviceQuietHoursCommand'); + }); + + describe('#handle()', function () { + it('should send locate device command', async function () { + const command = new SetDeviceQuietHoursCommand({ + deviceId: new ID(1), + quietHours: new QuietHoursSetting({ + isEnabled: true, + beginTime: DeviceTime.fromMinutes(0), + endTime: DeviceTime.fromMinutes(180), + }), + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + + await commandHandler.handle(command); + + verify( + packetConnection.sendAndWait( + 'USER_SET_DEVICE_QUIETHOURS_REQ', + deepEqual({ + isOpen: true, + beginTime: 0, + endTime: 180, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('USER_SET_DEVICE_QUIETHOURS_RSP')).once(); + }); + + it('should do nothing when no connection is found', async function () { + const command = new SetDeviceQuietHoursCommand({ + deviceId: new ID(1), + quietHours: new QuietHoursSetting(givenSomeQuietHoursSettingProps()), + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.ts new file mode 100644 index 00000000..d6234dd2 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-quiet-hours.command-handler.ts @@ -0,0 +1,25 @@ +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, SetDeviceQuietHoursCommand } from '@agnoc/domain'; + +export class SetDeviceQuietHoursCommandHandler implements CommandHandler { + readonly forName = 'SetDeviceQuietHoursCommand'; + + constructor(private readonly packetConnectionFinderService: PacketConnectionFinderService) {} + + async handle(event: SetDeviceQuietHoursCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + const response: PacketMessage = await connection.sendAndWait('USER_SET_DEVICE_QUIETHOURS_REQ', { + isOpen: event.quietHours.isEnabled, + beginTime: event.quietHours.beginTime.toMinutes(), + endTime: event.quietHours.endTime.toMinutes(), + }); + + response.assertPayloadName('USER_SET_DEVICE_QUIETHOURS_RSP'); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.test.ts new file mode 100644 index 00000000..6075584b --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.test.ts @@ -0,0 +1,110 @@ +import { DeviceSettings, SetDeviceVoiceCommand, VoiceSetting } from '@agnoc/domain'; +import { givenSomeDeviceSettingsProps } from '@agnoc/domain/test-support'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetDeviceVoiceCommandHandler } from './set-device-voice.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { VoiceSettingMapper } from '../mappers/voice-setting.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('SetDeviceVoiceCommandHandler', function () { + let voiceSettingMapper: VoiceSettingMapper; + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetDeviceVoiceCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + voiceSettingMapper = imock(); + deviceRepository = imock(); + packetConnectionFinderService = imock(); + commandHandler = new SetDeviceVoiceCommandHandler( + instance(packetConnectionFinderService), + instance(voiceSettingMapper), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetDeviceVoiceCommand'); + }); + + describe('#handle()', function () { + it('should change and set voice setting', async function () { + const voice = { isEnabled: true, volume: 5 }; + const voiceSetting = new VoiceSetting({ isEnabled: true, volume: 50 }); + const deviceSettings = new DeviceSettings(givenSomeDeviceSettingsProps()); + const command = new SetDeviceVoiceCommand({ + deviceId: new ID(1), + voice: voiceSetting, + }); + + when(voiceSettingMapper.fromDomain(anything())).thenReturn(voice); + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.settings).thenReturn(deviceSettings); + + await commandHandler.handle(command); + + verify(voiceSettingMapper.fromDomain(command.voice)).once(); + verify( + packetConnection.sendAndWait('USER_SET_DEVICE_CTRL_SETTING_REQ', deepEqual({ voiceMode: true, volume: 5 })), + ).once(); + verify(packetMessage.assertPayloadName('USER_SET_DEVICE_CTRL_SETTING_RSP')).once(); + verify( + device.updateSettings( + deepEqual(new DeviceSettings({ ...givenSomeDeviceSettingsProps(), voice: voiceSetting })), + ), + ).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const voiceSetting = new VoiceSetting({ isEnabled: true, volume: 50 }); + const command = new SetDeviceVoiceCommand({ + deviceId: new ID(1), + voice: voiceSetting, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + + it('should throw an error when device has no settings', async function () { + const voiceSetting = new VoiceSetting({ isEnabled: true, volume: 50 }); + const command = new SetDeviceVoiceCommand({ + deviceId: new ID(1), + voice: voiceSetting, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.settings).thenReturn(undefined); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to set voice setting when device settings are not available', + ); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.ts new file mode 100644 index 00000000..160366d2 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-device-voice.command-handler.ts @@ -0,0 +1,70 @@ +import { DomainException } from '@agnoc/toolkit'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { VoiceSettingMapper } from '../mappers/voice-setting.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { + CommandHandler, + ConnectionWithDevice, + Device, + DeviceRepository, + DeviceSettings, + SetDeviceVoiceCommand, +} from '@agnoc/domain'; + +export class SetDeviceVoiceCommandHandler implements CommandHandler { + readonly forName = 'SetDeviceVoiceCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly voiceSettingMapper: VoiceSettingMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: SetDeviceVoiceCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + this.assertDeviceSettings(connection); + + await this.sendDeviceVoice(event, connection); + await this.updateDeviceSettings(connection, event); + } + + private assertDeviceSettings( + connection: PacketConnection & ConnectionWithDevice, + ): asserts connection is PacketConnection & ConnectionWithDevice { + if (!connection.device.settings) { + throw new DomainException('Unable to set voice setting when device settings are not available'); + } + } + + private async updateDeviceSettings( + connection: PacketConnection & ConnectionWithDevice, + event: SetDeviceVoiceCommand, + ) { + const settings = connection.device.settings.clone({ voice: event.voice }); + + connection.device.updateSettings(settings); + + await this.deviceRepository.saveOne(connection.device); + } + + private async sendDeviceVoice(event: SetDeviceVoiceCommand, connection: PacketConnection & ConnectionWithDevice) { + const { isEnabled, volume } = this.voiceSettingMapper.fromDomain(event.voice); + + const response: PacketMessage = await connection.sendAndWait('USER_SET_DEVICE_CTRL_SETTING_REQ', { + voiceMode: isEnabled, + volume, + }); + + response.assertPayloadName('USER_SET_DEVICE_CTRL_SETTING_RSP'); + } +} + +interface DeviceWithSettings extends Device { + settings: DeviceSettings; +} diff --git a/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.test.ts new file mode 100644 index 00000000..463a7c0e --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.test.ts @@ -0,0 +1,71 @@ +import { DeviceFanSpeed, DeviceFanSpeedValue, SetFanSpeedCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetFanSpeedCommandHandler } from './set-fan-speed.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('SetFanSpeedCommandHandler', function () { + let deviceFanSpeedMapper: DeviceFanSpeedMapper; + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetFanSpeedCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + deviceFanSpeedMapper = imock(); + deviceRepository = imock(); + packetConnectionFinderService = imock(); + commandHandler = new SetFanSpeedCommandHandler( + instance(packetConnectionFinderService), + instance(deviceFanSpeedMapper), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetFanSpeedCommand'); + }); + + describe('#handle()', function () { + it('should set the fan speed', async function () { + const fanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const command = new SetFanSpeedCommand({ deviceId: new ID(1), fanSpeed }); + + when(deviceFanSpeedMapper.fromDomain(anything())).thenReturn(1); + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_SET_CLEAN_PREFERENCE_REQ', deepEqual({ mode: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_SET_CLEAN_PREFERENCE_RSP')).once(); + verify(device.updateFanSpeed(fanSpeed)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const fanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const command = new SetFanSpeedCommand({ deviceId: new ID(1), fanSpeed }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.ts new file mode 100644 index 00000000..e2aa3e12 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-fan-speed.command-handler.ts @@ -0,0 +1,40 @@ +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, ConnectionWithDevice, DeviceRepository, SetFanSpeedCommand } from '@agnoc/domain'; + +export class SetFanSpeedCommandHandler implements CommandHandler { + readonly forName = 'SetFanSpeedCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceFanSpeedMapper: DeviceFanSpeedMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: SetFanSpeedCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + await this.setFanSpeed(event, connection); + await this.updateDevice(event, connection); + } + + private async setFanSpeed({ fanSpeed }: SetFanSpeedCommand, connection: PacketConnection & ConnectionWithDevice) { + const response: PacketMessage = await connection.sendAndWait('DEVICE_SET_CLEAN_PREFERENCE_REQ', { + mode: this.deviceFanSpeedMapper.fromDomain(fanSpeed), + }); + + response.assertPayloadName('DEVICE_SET_CLEAN_PREFERENCE_RSP'); + } + + private async updateDevice({ fanSpeed }: SetFanSpeedCommand, connection: PacketConnection & ConnectionWithDevice) { + connection.device.updateFanSpeed(fanSpeed); + + await this.deviceRepository.saveOne(connection.device); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.test.ts new file mode 100644 index 00000000..ac149c1f --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.test.ts @@ -0,0 +1,71 @@ +import { DeviceWaterLevel, DeviceWaterLevelValue, SetWaterLevelCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetWaterLevelCommandHandler } from './set-water-level.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceWaterLevelMapper } from '../mappers/device-water-level.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('SetWaterLevelCommandHandler', function () { + let deviceWaterLevelMapper: DeviceWaterLevelMapper; + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: SetWaterLevelCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + deviceWaterLevelMapper = imock(); + deviceRepository = imock(); + packetConnectionFinderService = imock(); + commandHandler = new SetWaterLevelCommandHandler( + instance(packetConnectionFinderService), + instance(deviceWaterLevelMapper), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('SetWaterLevelCommand'); + }); + + describe('#handle()', function () { + it('should set the fan speed', async function () { + const waterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Low); + const command = new SetWaterLevelCommand({ deviceId: new ID(1), waterLevel }); + + when(deviceWaterLevelMapper.fromDomain(anything())).thenReturn(1); + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait('DEVICE_SET_CLEAN_PREFERENCE_REQ', deepEqual({ mode: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_SET_CLEAN_PREFERENCE_RSP')).once(); + verify(device.updateWaterLevel(waterLevel)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const waterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Low); + const command = new SetWaterLevelCommand({ deviceId: new ID(1), waterLevel }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + verify(device.updateSettings(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.ts b/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.ts new file mode 100644 index 00000000..e84067e2 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/set-water-level.command-handler.ts @@ -0,0 +1,46 @@ +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceWaterLevelMapper } from '../mappers/device-water-level.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, ConnectionWithDevice, DeviceRepository, SetWaterLevelCommand } from '@agnoc/domain'; + +export class SetWaterLevelCommandHandler implements CommandHandler { + readonly forName = 'SetWaterLevelCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceWaterLevelMapper: DeviceWaterLevelMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: SetWaterLevelCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + await this.setWaterLevel(event, connection); + await this.updateDevice(event, connection); + } + + private async setWaterLevel( + { waterLevel }: SetWaterLevelCommand, + connection: PacketConnection & ConnectionWithDevice, + ) { + const response: PacketMessage = await connection.sendAndWait('DEVICE_SET_CLEAN_PREFERENCE_REQ', { + mode: this.deviceWaterLevelMapper.fromDomain(waterLevel), + }); + + response.assertPayloadName('DEVICE_SET_CLEAN_PREFERENCE_RSP'); + } + + private async updateDevice( + { waterLevel }: SetWaterLevelCommand, + connection: PacketConnection & ConnectionWithDevice, + ) { + connection.device.updateWaterLevel(waterLevel); + + await this.deviceRepository.saveOne(connection.device); + } +} diff --git a/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.test.ts new file mode 100644 index 00000000..28a3ea8a --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.test.ts @@ -0,0 +1,237 @@ +import { + DeviceMap, + DeviceMode, + DeviceModeValue, + DeviceState, + DeviceStateValue, + MapPosition, + StartCleaningCommand, +} from '@agnoc/domain'; +import { givenSomeDeviceMapProps } from '@agnoc/domain/test-support'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import { StartCleaningCommandHandler } from './start-cleaning.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceMapService } from '../services/device-map.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, Device, DeviceSystem } from '@agnoc/domain'; + +describe('StartCleaningCommandHandler', function () { + let deviceCleaningService: DeviceCleaningService; + let deviceMapService: DeviceMapService; + let packetConnectionFinderService: PacketConnectionFinderService; + let deviceModeChangerService: DeviceModeChangerService; + let commandHandler: StartCleaningCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let device: Device; + let deviceSystem: DeviceSystem; + + beforeEach(function () { + deviceCleaningService = imock(); + deviceMapService = imock(); + packetConnectionFinderService = imock(); + deviceModeChangerService = imock(); + commandHandler = new StartCleaningCommandHandler( + instance(packetConnectionFinderService), + instance(deviceModeChangerService), + instance(deviceCleaningService), + instance(deviceMapService), + ); + packetConnection = imock(); + device = imock(); + deviceSystem = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('StartCleaningCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceModeChangerService.changeMode(anything(), anything())).never(); + verify(deviceCleaningService.autoCleaning(anything(), anything())).never(); + }); + + it('should start auto cleaning', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(false); + when(device.mode).thenReturn(undefined); + when(device.hasMopAttached).thenReturn(false); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.None)), + ), + ).once(); + verify(deviceCleaningService.autoCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should enable whole clean when supported', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + const deviceMap = new DeviceMap(givenSomeDeviceMapProps()); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(device.mode).thenReturn(undefined); + when(device.state).thenReturn(new DeviceState(DeviceStateValue.Docked)); + when(device.hasMopAttached).thenReturn(false); + when(device.map).thenReturn(deviceMap); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.None)), + ), + ).once(); + verify( + deviceMapService.enableWholeClean( + instance(packetConnection) as PacketConnection & ConnectionWithDevice, + ), + ).once(); + verify(deviceCleaningService.autoCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should not enable whole clean when device has no map', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(device.mode).thenReturn(undefined); + when(device.state).thenReturn(new DeviceState(DeviceStateValue.Docked)); + when(device.hasMopAttached).thenReturn(false); + when(device.map).thenReturn(undefined); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.None)), + ), + ).once(); + verify(deviceMapService.enableWholeClean(anything())).never(); + verify(deviceCleaningService.autoCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should start zone cleaning', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Zone)); + when(device.hasMopAttached).thenReturn(false); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.None)), + ), + ).once(); + verify(deviceCleaningService.zoneCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should start mop cleaning', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(device.hasMopAttached).thenReturn(true); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode(instance(packetConnection), deepEqual(new DeviceMode(DeviceModeValue.Mop))), + ).once(); + verify(deviceCleaningService.mopCleaning(instance(packetConnection), ModeCtrlValue.Start)).once(); + }); + + it('should start spot cleaning', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + const currentSpot = new MapPosition({ x: 0, y: 0, phi: 0 }); + const deviceMap = new DeviceMap({ + ...givenSomeDeviceMapProps(), + currentSpot, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.hasMopAttached).thenReturn(false); + when(device.map).thenReturn(deviceMap); + + await commandHandler.handle(command); + + verify( + deviceModeChangerService.changeMode( + instance(packetConnection), + deepEqual(new DeviceMode(DeviceModeValue.None)), + ), + ).once(); + verify(deviceCleaningService.spotCleaning(instance(packetConnection), currentSpot, ModeCtrlValue.Start)).once(); + }); + + it('should throw an error when trying to spot clean without map', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.hasMopAttached).thenReturn(false); + when(device.map).thenReturn(undefined); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to start spot cleaning, no map available', + ); + }); + + it('should throw an error when trying to spot clean without current spot', async function () { + const command = new StartCleaningCommand({ deviceId: new ID(1) }); + const deviceMap = new DeviceMap({ + ...givenSomeDeviceMapProps(), + currentSpot: undefined, + }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + when(device.hasMopAttached).thenReturn(false); + when(device.map).thenReturn(deviceMap); + + await expect(commandHandler.handle(command)).to.be.rejectedWith( + DomainException, + 'Unable to start spot cleaning, no spot selected', + ); + }); + }); +}); + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.ts b/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.ts new file mode 100644 index 00000000..1aabd7a0 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/start-cleaning.command-handler.ts @@ -0,0 +1,84 @@ +import { DeviceCapability, DeviceMode, DeviceModeValue, DeviceStateValue } from '@agnoc/domain'; +import { DomainException } from '@agnoc/toolkit'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { DeviceMapService } from '../services/device-map.service'; +import type { DeviceModeChangerService } from '../services/device-mode-changer.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, StartCleaningCommand, ConnectionWithDevice, Device, DeviceMap } from '@agnoc/domain'; + +export class StartCleaningCommandHandler implements CommandHandler { + readonly forName = 'StartCleaningCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceModeChangerService: DeviceModeChangerService, + private readonly deviceCleaningService: DeviceCleaningService, + private readonly deviceMapService: DeviceMapService, + ) {} + + async handle(event: StartCleaningCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + await this.changeDeviceMode(connection); + + const device = connection.device; + const deviceModeValue = device.mode?.value; + + if (deviceModeValue === DeviceModeValue.Zone) { + return this.deviceCleaningService.zoneCleaning(connection, ModeCtrlValue.Start); + } + + if (deviceModeValue === DeviceModeValue.Mop) { + return this.deviceCleaningService.mopCleaning(connection, ModeCtrlValue.Start); + } + + if (deviceModeValue === DeviceModeValue.Spot) { + return this.startSpotCleaning(connection); + } + + if (this.isDockedAndSupportsMapPlansAndHasMap(connection)) { + await this.deviceMapService.enableWholeClean(connection); + } + + return this.deviceCleaningService.autoCleaning(connection, ModeCtrlValue.Start); + } + + private async changeDeviceMode(connection: PacketConnection & ConnectionWithDevice) { + const deviceModeValue = connection.device.hasMopAttached ? DeviceModeValue.Mop : DeviceModeValue.None; + const deviceMode = new DeviceMode(deviceModeValue); + + await this.deviceModeChangerService.changeMode(connection, deviceMode); + } + + private async startSpotCleaning(connection: PacketConnection & ConnectionWithDevice) { + if (!connection.device.map) { + throw new DomainException('Unable to start spot cleaning, no map available'); + } + + if (!connection.device.map.currentSpot) { + throw new DomainException('Unable to start spot cleaning, no spot selected'); + } + + return this.deviceCleaningService.spotCleaning(connection, connection.device.map.currentSpot, ModeCtrlValue.Start); + } + + private isDockedAndSupportsMapPlansAndHasMap( + connection: PacketConnection & ConnectionWithDevice, + ): connection is PacketConnection & ConnectionWithDevice { + const supportsMapPlans = connection.device.system.supports(DeviceCapability.MAP_PLANS); + const deviceStateValue = connection.device.state?.value; + const hasMap = connection.device.map; + + return Boolean(supportsMapPlans && deviceStateValue === DeviceStateValue.Docked && hasMap); + } +} + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.test.ts b/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.test.ts new file mode 100644 index 00000000..b2300038 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.test.ts @@ -0,0 +1,53 @@ +import { StopCleaningCommand } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import { StopCleaningCommandHandler } from './stop-cleaning.command-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('StopCleaningCommandHandler', function () { + let deviceCleaningService: DeviceCleaningService; + let packetConnectionFinderService: PacketConnectionFinderService; + let commandHandler: StopCleaningCommandHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + + beforeEach(function () { + deviceCleaningService = imock(); + packetConnectionFinderService = imock(); + commandHandler = new StopCleaningCommandHandler( + instance(packetConnectionFinderService), + instance(deviceCleaningService), + ); + packetConnection = imock(); + }); + + it('should define the name', function () { + expect(commandHandler.forName).to.be.equal('StopCleaningCommand'); + }); + + describe('#handle()', function () { + it('should do nothing when no connection is found', async function () { + const command = new StopCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await commandHandler.handle(command); + + verify(deviceCleaningService.autoCleaning(anything(), anything())).never(); + }); + + it('should stop cleaning', async function () { + const command = new StopCleaningCommand({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + + await commandHandler.handle(command); + + verify(deviceCleaningService.autoCleaning(instance(packetConnection), ModeCtrlValue.Stop)).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.ts b/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.ts new file mode 100644 index 00000000..630171a8 --- /dev/null +++ b/packages/adapter-tcp/src/command-handlers/stop-cleaning.command-handler.ts @@ -0,0 +1,23 @@ +import { ModeCtrlValue } from '../services/device-mode-changer.service'; +import type { DeviceCleaningService } from '../services/device-cleaning.service'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { CommandHandler, StopCleaningCommand } from '@agnoc/domain'; + +export class StopCleaningCommandHandler implements CommandHandler { + readonly forName = 'StopCleaningCommand'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceCleaningService: DeviceCleaningService, + ) {} + + async handle(event: StopCleaningCommand): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + return; + } + + return this.deviceCleaningService.autoCleaning(connection, ModeCtrlValue.Stop); + } +} diff --git a/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.test.ts b/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.test.ts new file mode 100644 index 00000000..e9e59f61 --- /dev/null +++ b/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.test.ts @@ -0,0 +1,75 @@ +import { ID } from '@agnoc/toolkit'; +import { Packet } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { + anything, + capture, + deepEqual, + defer, + greaterThanOrEqual, + imock, + instance, + verify, + when, +} from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { NTPServerConnectionHandler } from './ntp-server.connection-handler'; +import type { PacketServer, PacketFactory, PacketSocket } from '@agnoc/transport-tcp'; + +describe('TimeSyncServer', function () { + let packetServer: PacketServer; + let packetFactory: PacketFactory; + let packetSocket: PacketSocket; + let rtpPacketServer: NTPServerConnectionHandler; + + beforeEach(function () { + packetServer = imock(); + packetFactory = imock(); + packetSocket = imock(); + rtpPacketServer = new NTPServerConnectionHandler(instance(packetFactory)); + }); + + describe('#register()', function () { + it('should register servers that handles connections', async function () { + const packet = new Packet(givenSomePacketProps()); + const now = Math.floor(Date.now() / 1000); + const onClose = defer(); + + when(packetFactory.create(anything(), anything(), anything())).thenReturn(packet); + when(packetServer.once(anything())).thenReturn(onClose); + + rtpPacketServer.register(instance(packetServer)); + + verify(packetServer.on('connection', anything())).once(); + + const [event, handler] = capture(packetServer.on<'connection'>).first(); + + expect(event).to.be.equal('connection'); + + await handler(instance(packetSocket)); + + verify( + packetFactory.create( + 'DEVICE_TIME_SYNC_RSP', + deepEqual({ result: 0, body: { time: greaterThanOrEqual(now) } }), + deepEqual({ userId: new ID(0), deviceId: new ID(0) }), + ), + ).once(); + verify(packetSocket.end(packet)).once(); + }); + + it('should clean up listeners when the server is closed', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenReturn(onClose); + + rtpPacketServer.register(instance(packetServer)); + + const [, handler] = capture(packetServer.on<'connection'>).first(); + + await onClose.resolve(undefined); + + verify(packetServer.off('connection', handler)).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.ts b/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.ts new file mode 100644 index 00000000..3c48aef8 --- /dev/null +++ b/packages/adapter-tcp/src/connection-handlers/ntp-server.connection-handler.ts @@ -0,0 +1,36 @@ +import { ID } from '@agnoc/toolkit'; +import type { PacketServer, PacketFactory, PacketSocket } from '@agnoc/transport-tcp'; + +/** Handler for NTP server connections. */ +export class NTPServerConnectionHandler { + constructor(private readonly packetFactory: PacketFactory) {} + + /** + * Register a server to handle connections. + * + * This must be done before the server is started. + */ + register(...servers: PacketServer[]): void { + servers.forEach((server) => { + this.addListeners(server); + }); + } + + private async handleConnection(socket: PacketSocket) { + const props = { userId: new ID(0), deviceId: new ID(0) }; + const payload = { result: 0, body: { time: Math.floor(Date.now() / 1000) } }; + const packet = this.packetFactory.create('DEVICE_TIME_SYNC_RSP', payload, props); + + return socket.end(packet); + } + + private addListeners(packetServer: PacketServer) { + const onConnection = this.handleConnection.bind(this); + + packetServer.on('connection', onConnection); + + void packetServer.once('close').then(() => { + packetServer.off('connection', onConnection); + }); + } +} diff --git a/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.test.ts b/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.test.ts new file mode 100644 index 00000000..b109c18f --- /dev/null +++ b/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.test.ts @@ -0,0 +1,152 @@ +import { anything, capture, defer, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { PacketMessage } from '../objects/packet.message'; +import { PackerServerConnectionHandler } from './packet-server.connection-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketConnectionFactory } from '../factories/connection.factory'; +import type { ConnectionDeviceUpdaterService } from '../services/connection-device-updater.service'; +import type { PacketEventPublisherService } from '../services/packet-event-publisher.service'; +import type { ConnectionRepository } from '@agnoc/domain'; +import type { Packet, PacketServer, PacketSocket } from '@agnoc/transport-tcp'; + +describe('PackerServerConnectionHandler', function () { + let connectionRepository: ConnectionRepository; + let packetConnectionFactory: PacketConnectionFactory; + let updateConnectionDeviceService: ConnectionDeviceUpdaterService; + let packetEventPublisherService: PacketEventPublisherService; + let handler: PackerServerConnectionHandler; + let packetServer: PacketServer; + let packetSocket: PacketSocket; + let packetConnection: PacketConnection; + let packet: Packet; + + beforeEach(function () { + connectionRepository = imock(); + packetConnectionFactory = imock(); + updateConnectionDeviceService = imock(); + packetEventPublisherService = imock(); + handler = new PackerServerConnectionHandler( + instance(connectionRepository), + instance(packetConnectionFactory), + instance(updateConnectionDeviceService), + instance(packetEventPublisherService), + ); + packetServer = imock(); + packetSocket = imock(); + packetConnection = imock(); + packet = imock(); + }); + + describe('#addServers', function () { + it('should register a server and listen for connections', function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + verify(packetServer.on('connection', anything())).once(); + verify(packetServer.once('close')).once(); + }); + + it('should handle connections', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + when(packetConnectionFactory.create(anything())).thenReturn(instance(packetConnection)); + when(packetConnection.socket).thenReturn(instance(packetSocket)); + + const [, onConnection] = capture<'connection', (socket: PacketSocket) => Promise>(packetServer.on).first(); + + await onConnection(instance(packetSocket)); + + verify(connectionRepository.saveOne(instance(packetConnection))).once(); + verify(packetSocket.on('data', anything())).once(); + verify(packetSocket.once('close', anything())).once(); + }); + + it('should close connections on server close', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + when(packetConnectionFactory.create(anything())).thenReturn(instance(packetConnection)); + when(packetConnection.socket).thenReturn(instance(packetSocket)); + + const [, onConnection] = capture<'connection', (socket: PacketSocket) => Promise>(packetServer.on).first(); + + await onConnection(instance(packetSocket)); + await onClose.resolve(undefined); + + verify(packetServer.off('connection', onConnection)).once(); + verify(packetConnection.close()).once(); + }); + + it('should server close without connections', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + await onClose.resolve(undefined); + + verify(packetConnection.close()).never(); + }); + + it('should handle connection data', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + when(packetConnectionFactory.create(anything())).thenReturn(instance(packetConnection)); + when(packetConnection.socket).thenReturn(instance(packetSocket)); + + const [, onConnection] = capture<'connection', (socket: PacketSocket) => Promise>(packetServer.on).first(); + + await onConnection(instance(packetSocket)); + + const [, onData] = capture<'data', (packet: Packet) => Promise>(packetSocket.on).first(); + + await onData(instance(packet)); + + verify(updateConnectionDeviceService.updateFromPacket(instance(packet), instance(packetConnection))).once(); + + const [packetMessage] = capture(packetEventPublisherService.publishPacketMessage).first(); + + expect(packetMessage).to.be.instanceOf(PacketMessage); + expect(packetMessage.packet).to.equal(instance(packet)); + expect(packetMessage.connection).to.equal(instance(packetConnection)); + }); + + it('should handle connection close', async function () { + const onClose = defer(); + + when(packetServer.once(anything())).thenResolve(onClose); + + handler.register(instance(packetServer)); + + when(packetConnectionFactory.create(anything())).thenReturn(instance(packetConnection)); + when(packetConnection.socket).thenReturn(instance(packetSocket)); + + const [, onConnection] = capture<'connection', (socket: PacketSocket) => Promise>(packetServer.on).first(); + + await onConnection(instance(packetSocket)); + + const [, onData] = capture<'data', (packet: Packet) => Promise>(packetSocket.on).first(); + const [, onConnectionClose] = capture<'close', () => Promise>(packetSocket.once).first(); + + await onConnectionClose(); + + verify(packetSocket.off('data', onData)).once(); + verify(connectionRepository.deleteOne(instance(packetConnection))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.ts b/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.ts new file mode 100644 index 00000000..6c8e6bbc --- /dev/null +++ b/packages/adapter-tcp/src/connection-handlers/packet-server.connection-handler.ts @@ -0,0 +1,75 @@ +import { ID } from '@agnoc/toolkit'; +import { PacketMessage } from '../objects/packet.message'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketConnectionFactory } from '../factories/connection.factory'; +import type { ConnectionDeviceUpdaterService } from '../services/connection-device-updater.service'; +import type { PacketEventPublisherService } from '../services/packet-event-publisher.service'; +import type { ConnectionRepository } from '@agnoc/domain'; +import type { PacketServer, Packet, PacketSocket } from '@agnoc/transport-tcp'; + +export class PackerServerConnectionHandler { + private readonly servers = new Map>(); + + constructor( + private readonly connectionRepository: ConnectionRepository, + private readonly packetConnectionFactory: PacketConnectionFactory, + private readonly updateConnectionDeviceService: ConnectionDeviceUpdaterService, + private readonly packetEventPublisherService: PacketEventPublisherService, + ) {} + + register(...servers: PacketServer[]): void { + servers.forEach((server) => { + this.servers.set(server, new Set()); + this.addListeners(server); + }); + } + + private addListeners(server: PacketServer) { + const onConnection = (socket: PacketSocket) => this.handleServerConnection(server, socket); + + server.on('connection', onConnection); + + void server.once('close').then(() => { + server.off('connection', onConnection); + + return this.handleServerClose(server); + }); + } + + private async handleServerClose(server: PacketServer) { + const connections = this.servers.get(server) as Set; + + this.servers.delete(server); + + await Promise.all([...connections].map((connection) => connection.close())); + } + + private async handleServerConnection(server: PacketServer, socket: PacketSocket) { + const connection = this.packetConnectionFactory.create({ id: ID.generate(), socket }); + + this.servers.get(server)?.add(connection); + + // Should this be done before or after registering the listeners? + await this.connectionRepository.saveOne(connection); + + const onData = (packet: Packet) => this.handleConnectionData(connection, packet); + const onClose = () => { + connection.socket.off('data', onData); + this.servers.get(server)?.delete(connection); + return this.connectionRepository.deleteOne(connection); + }; + + connection.socket.on('data', onData); + connection.socket.once('close', onClose); + } + + private async handleConnectionData(connection: PacketConnection, packet: Packet) { + const packetMessage = new PacketMessage(connection, packet); + + // Update the device on the connection if the device id has changed. + await this.updateConnectionDeviceService.updateFromPacket(packet, connection); + + // Send the packet message to the packet event bus. + await this.packetEventPublisherService.publishPacketMessage(packetMessage); + } +} diff --git a/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.test.ts b/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.test.ts new file mode 100644 index 00000000..964bc8ad --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.test.ts @@ -0,0 +1,46 @@ +import { DeviceConnectedDomainEvent } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { LockDeviceWhenDeviceIsConnectedEventHandler } from './lock-device-when-device-is-connected-event-handler.event-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('LockDeviceWhenDeviceIsConnectedEventHandler', function () { + let packetConnectionFinderService: PacketConnectionFinderService; + let eventHandler: LockDeviceWhenDeviceIsConnectedEventHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + + beforeEach(function () { + packetConnectionFinderService = imock(); + eventHandler = new LockDeviceWhenDeviceIsConnectedEventHandler(instance(packetConnectionFinderService)); + packetConnection = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DeviceConnectedDomainEvent'); + }); + + describe('#handle()', function () { + it('should lock the device', async function () { + const event = new DeviceConnectedDomainEvent({ aggregateId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + + await eventHandler.handle(event); + + verify(packetConnection.send('DEVICE_CONTROL_LOCK_REQ', deepEqual({}))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const event = new DeviceConnectedDomainEvent({ aggregateId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await eventHandler.handle(event); + + verify(packetConnection.send(anything(), anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.ts b/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.ts new file mode 100644 index 00000000..ed417714 --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler.ts @@ -0,0 +1,18 @@ +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { DomainEventHandler, DeviceConnectedDomainEvent } from '@agnoc/domain'; + +export class LockDeviceWhenDeviceIsConnectedEventHandler implements DomainEventHandler { + readonly forName = 'DeviceConnectedDomainEvent'; + + constructor(private readonly packetConnectionFinderService: PacketConnectionFinderService) {} + + async handle(event: DeviceConnectedDomainEvent): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.aggregateId); + + if (!connection) { + return; + } + + await connection.send('DEVICE_CONTROL_LOCK_REQ', {}); + } +} diff --git a/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.test.ts b/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.test.ts new file mode 100644 index 00000000..a00c4f10 --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.test.ts @@ -0,0 +1,73 @@ +import { Device, DeviceLockedDomainEvent, DeviceSystem } from '@agnoc/domain'; +import { givenSomeDeviceProps, givenSomeDeviceSystemProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, anything, verify, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { QueryDeviceInfoWhenDeviceIsLockedEventHandler } from './query-device-info-when-device-is-locked-event-handler.event-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice } from '@agnoc/domain'; + +describe('QueryDeviceInfoWhenDeviceIsLockedEventHandler', function () { + let packetConnectionFinderService: PacketConnectionFinderService; + let eventHandler: QueryDeviceInfoWhenDeviceIsLockedEventHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + + beforeEach(function () { + packetConnectionFinderService = imock(); + eventHandler = new QueryDeviceInfoWhenDeviceIsLockedEventHandler(instance(packetConnectionFinderService)); + packetConnection = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DeviceLockedDomainEvent'); + }); + + describe('#handle()', function () { + it('should query device info for type C3490', async function () { + const system = new DeviceSystem({ ...givenSomeDeviceSystemProps(), type: 9 }); + const device = new Device({ ...givenSomeDeviceProps(), system }); + const event = new DeviceLockedDomainEvent({ aggregateId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(device); + + await eventHandler.handle(event); + + verify(packetConnection.send('DEVICE_ORDERLIST_GETTING_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_STATUS_GETTING_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_GET_ALL_GLOBAL_MAP_INFO_REQ', deepEqual({ unk1: 0, unk2: '' }))).once(); + verify(packetConnection.send('DEVICE_GETTIME_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x78ff }))).once(); + verify(packetConnection.send('DEVICE_WLAN_INFO_GETTING_REQ', deepEqual({}))).once(); + }); + + it('should query device info for type C3090', async function () { + const system = new DeviceSystem({ ...givenSomeDeviceSystemProps(), type: 3 }); + const device = new Device({ ...givenSomeDeviceProps(), system }); + const event = new DeviceLockedDomainEvent({ aggregateId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.device).thenReturn(device); + + await eventHandler.handle(event); + + verify(packetConnection.send('DEVICE_ORDERLIST_GETTING_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_STATUS_GETTING_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_GET_ALL_GLOBAL_MAP_INFO_REQ', deepEqual({ unk1: 0, unk2: '' }))).once(); + verify(packetConnection.send('DEVICE_GETTIME_REQ', deepEqual({}))).once(); + verify(packetConnection.send('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0xff }))).once(); + verify(packetConnection.send('DEVICE_WLAN_INFO_GETTING_REQ', deepEqual({}))).once(); + }); + + it('should do nothing when no connection is found', async function () { + const event = new DeviceLockedDomainEvent({ aggregateId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await eventHandler.handle(event); + + verify(packetConnection.send(anything(), anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.ts b/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.ts new file mode 100644 index 00000000..812b70af --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler.ts @@ -0,0 +1,32 @@ +import { DeviceCapability } from '@agnoc/domain'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { DeviceLockedDomainEvent, DomainEventHandler } from '@agnoc/domain'; + +export class QueryDeviceInfoWhenDeviceIsLockedEventHandler implements DomainEventHandler { + readonly forName = 'DeviceLockedDomainEvent'; + + constructor(private readonly packetConnectionFinderService: PacketConnectionFinderService) {} + + async handle(event: DeviceLockedDomainEvent): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.aggregateId); + + if (!connection) { + return; + } + + await connection.send('DEVICE_ORDERLIST_GETTING_REQ', {}); + await connection.send('DEVICE_STATUS_GETTING_REQ', {}); + await connection.send('DEVICE_GET_ALL_GLOBAL_MAP_INFO_REQ', { unk1: 0, unk2: '' }); + + // TODO: move this to a get time service. + await connection.send('DEVICE_GETTIME_REQ', {}); + + // TODO: move this to a get map service. + await connection.send('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', { + mask: connection.device?.system.supports(DeviceCapability.MAP_PLANS) ? 0x78ff : 0xff, + }); + + // TODO: move this to a get network service. + await connection.send('DEVICE_WLAN_INFO_GETTING_REQ', {}); + } +} diff --git a/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.test.ts b/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.test.ts new file mode 100644 index 00000000..c0c14c07 --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.test.ts @@ -0,0 +1,93 @@ +import { ConnectionDeviceChangedDomainEvent } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler } from './set-device-connected-when-connection-device-changed.event-handler'; +import type { ConnectionRepository, DeviceRepository, Device, ConnectionWithDevice } from '@agnoc/domain'; + +describe('SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler', function () { + let connectionRepository: ConnectionRepository; + let deviceRepository: DeviceRepository; + let eventHandler: SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler; + let connection: ConnectionWithDevice; + let device: Device; + + beforeEach(function () { + connectionRepository = imock(); + deviceRepository = imock(); + eventHandler = new SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler( + instance(connectionRepository), + instance(deviceRepository), + ); + connection = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('ConnectionDeviceChangedDomainEvent'); + }); + + describe('#handle()', function () { + it('should set device as connected when there are more than one connection', async function () { + const currentDeviceId = new ID(2); + const event = new ConnectionDeviceChangedDomainEvent({ aggregateId: new ID(1), currentDeviceId }); + + when(connectionRepository.findByDeviceId(anything())).thenResolve([instance(connection), instance(connection)]); + when(deviceRepository.findOneById(anything())).thenResolve(instance(device)); + when(connection.connectionType).thenReturn('PACKET'); + when(device.isConnected).thenReturn(false); + + await eventHandler.handle(event); + + verify(connectionRepository.findByDeviceId(currentDeviceId)).once(); + verify(deviceRepository.findOneById(currentDeviceId)).once(); + verify(device.setAsConnected()).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when there is no current device id', async function () { + const event = new ConnectionDeviceChangedDomainEvent({ aggregateId: new ID(1) }); + + await eventHandler.handle(event); + + verify(connectionRepository.findByDeviceId(anything())).never(); + verify(deviceRepository.findOneById(anything())).never(); + verify(device.setAsConnected()).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + + it('should do nothing when there is only one connection', async function () { + const currentDeviceId = new ID(2); + const event = new ConnectionDeviceChangedDomainEvent({ aggregateId: new ID(1), currentDeviceId }); + + when(connectionRepository.findByDeviceId(anything())).thenResolve([instance(connection)]); + when(deviceRepository.findOneById(anything())).thenResolve(instance(device)); + when(connection.connectionType).thenReturn('PACKET'); + when(device.isConnected).thenReturn(false); + + await eventHandler.handle(event); + + verify(connectionRepository.findByDeviceId(currentDeviceId)).once(); + verify(deviceRepository.findOneById(currentDeviceId)).once(); + verify(device.setAsConnected()).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + + it('should do nothing when connections are not packet connections', async function () { + const currentDeviceId = new ID(2); + const event = new ConnectionDeviceChangedDomainEvent({ aggregateId: new ID(1), currentDeviceId }); + + when(connectionRepository.findByDeviceId(anything())).thenResolve([instance(connection), instance(connection)]); + when(deviceRepository.findOneById(anything())).thenResolve(instance(device)); + when(connection.connectionType).thenReturn('OTHER'); + when(device.isConnected).thenReturn(false); + + await eventHandler.handle(event); + + verify(connectionRepository.findByDeviceId(currentDeviceId)).once(); + verify(deviceRepository.findOneById(currentDeviceId)).once(); + verify(device.setAsConnected()).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.ts b/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.ts new file mode 100644 index 00000000..9326b4f3 --- /dev/null +++ b/packages/adapter-tcp/src/domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler.ts @@ -0,0 +1,37 @@ +import { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { + DomainEventHandler, + ConnectionRepository, + DeviceRepository, + ConnectionDeviceChangedDomainEvent, + Connection, +} from '@agnoc/domain'; + +export class SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler implements DomainEventHandler { + readonly forName = 'ConnectionDeviceChangedDomainEvent'; + + constructor( + private readonly connectionRepository: ConnectionRepository, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: ConnectionDeviceChangedDomainEvent): Promise { + if (event.currentDeviceId) { + const connections = await this.connectionRepository.findByDeviceId(event.currentDeviceId); + const device = await this.deviceRepository.findOneById(event.currentDeviceId); + const packetConnections = connections.filter((connection: Connection): connection is PacketConnection => + PacketConnection.isPacketConnection(connection), + ); + + // This is a hack to only mark the device as connected if there is more than one connection. + // Here we should check that the connections are from the same ip address. + if (packetConnections.length > 1 && device && !device.isConnected) { + device.setAsConnected(); + + await this.deviceRepository.saveOne(device); + } + } + + // TODO: handle device disconnection + } +} diff --git a/packages/adapter-tcp/src/emitters/cloud-server.emitter.ts b/packages/adapter-tcp/src/emitters/cloud-server.emitter.ts deleted file mode 100644 index 5025482b..00000000 --- a/packages/adapter-tcp/src/emitters/cloud-server.emitter.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { Device, DeviceSystem, DeviceVersion, User } from '@agnoc/domain'; -import { ID, bind } from '@agnoc/toolkit'; -import { PacketServer } from '@agnoc/transport-tcp'; -import { TypedEmitter } from 'tiny-typed-emitter'; -import { Message } from '../value-objects/message.value-object'; -import { Connection } from './connection.emitter'; -import { Multiplexer } from './multiplexer.emitter'; -import { Robot } from './robot.emitter'; -import type { MessageHandlers } from '../value-objects/message.value-object'; -import type { PacketMapper, PayloadObjectName, PacketSocket } from '@agnoc/transport-tcp'; - -interface Servers { - cmd: PacketServer; - map: PacketServer; - rtc: PacketServer; -} - -export interface CloudServerEvents { - addRobot: (robot: Robot) => void; - error: (err: Error) => void; -} - -export class CloudServer extends TypedEmitter { - private robots = new Map() as Map; - private servers: Servers; - private handlers: MessageHandlers = { - CLIENT_ONLINE_REQ: this.handleClientLogin, - DEVICE_REGISTER_REQ: this.handleClientRegister, - } as const; - - constructor(private readonly packetMapper: PacketMapper) { - super(); - this.servers = { - cmd: new PacketServer(this.packetMapper), - map: new PacketServer(this.packetMapper), - rtc: new PacketServer(this.packetMapper), - }; - this.addListeners(); - } - - getRobots(): Robot[] { - return [...this.robots.values()]; - } - - async listen(host?: string): Promise { - await Promise.all([ - this.servers.cmd.listen({ host, port: 4010 }), - this.servers.map.listen({ host, port: 4030 }), - this.servers.rtc.listen({ host, port: 4050 }), - ]); - } - - async close(): Promise { - this.getRobots().map((robot) => { - robot.disconnect(); - }); - - await Promise.all([this.servers.cmd.close(), this.servers.map.close(), this.servers.rtc.close()]); - } - - @bind - private handleClientLogin(message: Message<'CLIENT_ONLINE_REQ'>): void { - const robot = this.robots.get(message.packet.deviceId.value); - - if (!robot) { - const object = message.packet.payload.object; - - message.respond('CLIENT_ONLINE_RSP', { - result: 12002, - reason: `Device not registered(devsn: ${object.deviceSerialNumber})`, - }); - } else { - message.respond('CLIENT_ONLINE_RSP', { - result: 0, - }); - } - } - - @bind - private handleClientRegister(message: Message<'DEVICE_REGISTER_REQ'>): void { - const props = message.packet.payload.object; - const multiplexer = new Multiplexer(); - const device = new Device({ - id: ID.generate(), - system: new DeviceSystem({ type: props.deviceType }), - version: new DeviceVersion({ - software: props.softwareVersion, - hardware: props.hardwareVersion, - }), - }); - const user = new User({ - id: ID.generate(), - }); - const robot = new Robot({ device, user, multiplexer }); - - multiplexer.on('error', (err) => this.emit('error', err)); - - robot.addConnection(message.connection); - - this.robots.set(device.id.value, robot); - - this.emit('addRobot', robot); - - message.respond('DEVICE_REGISTER_RSP', { - result: 0, - device: { - id: device.id.value, - }, - }); - } - - @bind - handleMessage(message: Message): void { - const handler = this.handlers[message.opname]; - - if (handler) { - return handler(message); - } - - const robot = this.robots.get(message.packet.deviceId.value); - - if (robot) { - robot.addConnection(message.connection); - robot.handleMessage(message); - return; - } - - message.respond('COMMON_ERROR_REPLY', { - result: 1, - opcode: message.packet.payload.opcode.code, - error: 'Device not registered', - }); - } - - @bind - private handleConnection(socket: PacketSocket): void { - const connection = new Connection(socket); - - connection.on('data', (packet) => { - const message = new Message({ connection, packet }); - - this.handleMessage(message); - }); - } - - @bind - private handleRTPConnection(socket: PacketSocket) { - const connection = new Connection(socket); - - connection.send({ - opname: 'DEVICE_TIME_SYNC_RSP', - userId: new ID(0), - deviceId: new ID(0), - object: { - result: 0, - body: { - time: Math.floor(Date.now() / 1000), - }, - }, - }); - - connection.close(); - } - - private addListeners(): void { - this.servers.cmd.on('connection', this.handleConnection); - this.servers.map.on('connection', this.handleConnection); - this.servers.rtc.on('connection', this.handleRTPConnection); - } -} diff --git a/packages/adapter-tcp/src/emitters/connection.emitter.ts b/packages/adapter-tcp/src/emitters/connection.emitter.ts deleted file mode 100644 index a965b251..00000000 --- a/packages/adapter-tcp/src/emitters/connection.emitter.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { - debug, - bind, - DomainException, - isPresent, - ArgumentNotProvidedException, - ArgumentInvalidException, -} from '@agnoc/toolkit'; -import { Packet, PacketSocket, PacketSequence, OPCode, Payload } from '@agnoc/transport-tcp'; -import { TypedEmitter } from 'tiny-typed-emitter'; -import type { ID } from '@agnoc/toolkit'; -import type { PayloadObjectName, PayloadObjectFrom } from '@agnoc/transport-tcp'; -import type { Debugger } from 'debug'; - -export interface ConnectionSendProps { - opname: Name; - userId: ID; - deviceId: ID; - object: PayloadObjectFrom; -} - -export interface ConnectionRespondProps { - packet: Packet; - opname: Name; - object: PayloadObjectFrom; -} - -export type ConnectionEvents = { - [key in Name]: (packet: Packet) => void; -} & { - data: (packet: Packet) => void; - close: () => void; - error: (err: Error) => void; -}; - -export class Connection extends TypedEmitter> { - private socket: PacketSocket; - private debug: Debugger; - - constructor(socket: PacketSocket) { - super(); - this.validate(socket); - this.socket = socket; - this.addListeners(); - this.debug = debug(__filename).extend(this.toString()); - this.debug(`new connection`); - } - - private addListeners(): void { - this.socket.on('data', this.handlePacket); - this.socket.on('error', this.handleError); - this.socket.on('close', this.handleClose); - } - - send({ opname, userId, deviceId, object }: ConnectionSendProps): boolean { - const packet = new Packet({ - ctype: 2, - flow: 0, - // This swap is intended. - userId: deviceId, - deviceId: userId, - sequence: PacketSequence.generate(), - payload: new Payload({ opcode: OPCode.fromName(opname), object }), - }); - - this.debug(`sending packet ${packet.toString()}`); - - return this.write(packet); - } - - respond({ packet, opname, object }: ConnectionRespondProps): boolean { - const response = new Packet({ - ctype: packet.ctype, - flow: packet.flow + 1, - // This swap is intended. - userId: packet.deviceId, - deviceId: packet.userId, - sequence: packet.sequence, - payload: new Payload({ opcode: OPCode.fromName(opname), object }), - }); - - this.debug(`responding to packet with ${response.toString()}`); - - return this.write(response); - } - - private write(packet: Packet): boolean { - if (!this.socket.destroyed && !this.socket.connecting) { - return this.socket.write(packet); - } - - return false; - } - - close(): void { - this.debug('closing socket...'); - this.socket.end(); - } - - @bind - private handlePacket(packet: Packet): void { - this.validatePacket(packet); - - const opname = packet.payload.opcode.name as PayloadObjectName; - - if (!opname) { - throw new DomainException(`Unable to handle unknown packet ${packet.payload.opcode.toString()}`); - } - - this.debug(`received packet ${packet.toString()}`); - this.emit(opname, packet); - this.emit('data', packet); - } - - @bind - private handleError(err: Error): void { - this.emit('error', err); - } - - @bind - private handleClose(): void { - this.emit('close'); - } - - override toString(): string { - return `${this.socket.remoteAddress || 'unknown'}:${this.socket.remotePort || 0}::${ - this.socket.localAddress || 'unknown' - }:${this.socket.localPort || 0}`; - } - - protected validatePacket(packet: Packet): void { - if (!(packet instanceof Packet)) { - throw new DomainException('Connection socket emitted non-packet data'); - } - } - - protected validate(socket: PacketSocket): void { - if (!isPresent(socket)) { - throw new ArgumentNotProvidedException('Missing property in connection constructor'); - } - - if (!(socket instanceof PacketSocket)) { - throw new ArgumentInvalidException('Invalid socket in connection constructor'); - } - } -} diff --git a/packages/adapter-tcp/src/emitters/multiplexer.emitter.ts b/packages/adapter-tcp/src/emitters/multiplexer.emitter.ts deleted file mode 100644 index cf6757f8..00000000 --- a/packages/adapter-tcp/src/emitters/multiplexer.emitter.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { DomainException, debug, bind } from '@agnoc/toolkit'; -import { TypedEmitter } from 'tiny-typed-emitter'; -import type { Connection } from './connection.emitter'; -import type { ID } from '@agnoc/toolkit'; -import type { PayloadObjectName, Packet, PayloadObjectFrom } from '@agnoc/transport-tcp'; - -export type MultiplexerEvents = { - [key in Name]: (packet: Packet) => void; -} & { - data: (packet: Packet) => void; - error: (err: Error) => void; -}; - -export interface MultiplexerSendProps { - opname: Name; - userId: ID; - deviceId: ID; - object: PayloadObjectFrom; -} - -export class Multiplexer extends TypedEmitter> { - private connections: Connection[] = []; - private debug = debug(__filename); - - get hasConnections(): boolean { - return this.connections.length > 0; - } - - get connectionCount(): number { - return this.connections.length; - } - - addConnection(connection: Connection): boolean { - if (!this.connections.includes(connection)) { - connection.on('data', this.handlePacket); - connection.on('error', (err) => this.handleError(connection, err)); - connection.on('close', () => this.handleClose(connection)); - - this.connections = [...this.connections, connection]; - this.debug('added connection'); - - return true; - } - - return false; - } - - send(props: MultiplexerSendProps): boolean { - const connection = this.connections[0]; - - if (!connection) { - this.emit('error', new DomainException(`No valid connection found to send packet ${props.opname}`)); - - return false; - } - - return connection.send(props); - } - - close(): void { - this.debug('closing connections...'); - this.connections.forEach((connection) => { - connection.close(); - }); - } - - @bind - private handlePacket(packet: Packet): void { - const opname = packet.payload.opcode.name as PayloadObjectName; - - if (!opname) { - throw new DomainException(`Unable to handle unknown packet ${packet.payload.opcode.toString()}`); - } - - this.emit(opname, packet); - this.emit('data', packet); - } - - @bind - private handleError(connection: Connection, err: Error): void { - this.emit('error', err); - this.handleClose(connection); - } - - @bind - private handleClose(connection: Connection): void { - // TODO: fix this. - // Remove all listeners to prevent mem leaks. - // But ensure error handling works... - // connection.removeAllListeners(); - - this.connections = this.connections.filter((conn) => conn !== connection); - this.debug('removed connection'); - } -} diff --git a/packages/adapter-tcp/src/emitters/robot.emitter.ts b/packages/adapter-tcp/src/emitters/robot.emitter.ts deleted file mode 100644 index 1668dfa1..00000000 --- a/packages/adapter-tcp/src/emitters/robot.emitter.ts +++ /dev/null @@ -1,1215 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { - DeviceMode, - DeviceCapability, - DeviceConsumable, - DeviceWlan, - MapPosition, - MapCoordinate, - QuietHoursSetting, - DeviceTime, - Room, - DeviceVersion, - DeviceSettings, - DeviceCleanWork, - MapPixel, - DeviceMap, - Zone, - DeviceModeValue, - DeviceConsumableType, - DeviceStateValue, - CleanSize, - DeviceSetting, -} from '@agnoc/domain'; -import { - ArgumentInvalidException, - waitFor, - DomainException, - ID, - writeByte, - writeFloat, - isPresent, - debug, - bind, - BufferWriter, -} from '@agnoc/toolkit'; -import { TypedEmitter } from 'tiny-typed-emitter'; -import { CleanModeMapper } from '../mappers/clean-mode.mapper'; -import { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; -import { DeviceErrorMapper } from '../mappers/device-error.mapper'; -import { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; -import { DeviceModeMapper } from '../mappers/device-mode.mapper'; -import { DeviceOrderMapper } from '../mappers/device-order.mapper'; -import { DeviceStateMapper } from '../mappers/device-state.mapper'; -import { DeviceVoiceMapper } from '../mappers/device-voice.mapper'; -import { DeviceWaterLevelMapper } from '../mappers/device-water-level.mapper'; -import { WeekDayListMapper } from '../mappers/week-day-list.mapper'; -import type { Connection } from './connection.emitter'; -import type { Multiplexer } from './multiplexer.emitter'; -import type { Message, MessageHandlers } from '../value-objects/message.value-object'; -import type { - Device, - User, - DeviceFanSpeed, - DeviceWaterLevel, - DeviceOrder, - VoiceSetting, - DeviceSettingsProps, -} from '@agnoc/domain'; -import type { PayloadObjectName, PayloadObjectFrom, Packet } from '@agnoc/transport-tcp'; -import type { Debugger } from 'debug'; - -export interface RobotProps { - device: Device; - user: User; - multiplexer: Multiplexer; -} - -export interface DeviceTimestamp { - timestamp: number; - offset: number; -} - -export interface RobotEvents { - updateDevice: () => void; - updateMap: () => void; - updateRobotPosition: () => void; - updateChargerPosition: () => void; -} - -export enum MANUAL_MODE { - 'forward' = 1, - 'left' = 2, - 'right' = 3, - 'backward' = 4, - 'stop' = 5, - 'init' = 10, -} - -export type ManualMode = (typeof MANUAL_MODE)[keyof typeof MANUAL_MODE]; - -const MODE_CHANGE_TIMEOUT = 5000; -const RECV_TIMEOUT = 5000; - -const CONSUMABLE_TYPE_RESET = { - [DeviceConsumableType.MainBrush]: 1, - [DeviceConsumableType.SideBrush]: 2, - [DeviceConsumableType.Filter]: 3, - [DeviceConsumableType.Dishcloth]: 4, -}; - -const CTRL_VALUE = { - STOP: 0, - START: 1, - PAUSE: 2, -}; - -// TODO: move to constructor -const deviceFanSpeedMapper = new DeviceFanSpeedMapper(); -const deviceWaterLevelMapper = new DeviceWaterLevelMapper(); -const cleanModeMapper = new CleanModeMapper(); -const weekDayListMapper = new WeekDayListMapper(); -const deviceOrderMapper = new DeviceOrderMapper( - deviceFanSpeedMapper, - deviceWaterLevelMapper, - cleanModeMapper, - weekDayListMapper, -); -const deviceVoiceMapper = new DeviceVoiceMapper(); -const deviceStateMapper = new DeviceStateMapper(); -const deviceModeMapper = new DeviceModeMapper(); -const deviceErrorMapper = new DeviceErrorMapper(); -const deviceBatteryMapper = new DeviceBatteryMapper(); - -export class Robot extends TypedEmitter { - public readonly device: Device; - public readonly user: User; - private readonly multiplexer: Multiplexer; - private debug: Debugger; - private handlers: MessageHandlers = { - CLIENT_HEARTBEAT_REQ: this.handleClientHeartbeat, - DEVICE_MAPID_GET_GLOBAL_INFO_RSP: this.handleMapUpdate, - DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO: this.handleUpdateChargePosition, - DEVICE_MAPID_PUSH_MAP_INFO: this.handleMapUpdate, - DEVICE_MAPID_PUSH_POSITION_INFO: this.handleUpdateRobotPosition, - DEVICE_MAPID_WORK_STATUS_PUSH_REQ: this.handleDeviceStatus, - DEVICE_VERSION_INFO_UPDATE_REQ: this.handleDeviceVersionInfoUpdate, - PUSH_DEVICE_AGENT_SETTING_REQ: this.handleDeviceAgentSetting, - PUSH_DEVICE_BATTERY_INFO_REQ: this.handleDeviceBatteryInfo, - PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ: this.handleDevicePackageUpgrade, - DEVICE_MAPID_PUSH_HAS_WAITING_BE_SAVED: this.handleWaitingMap, - DEVICE_WORKSTATUS_REPORT_REQ: this.handleWorkstatusReport, - DEVICE_EVENT_REPORT_CLEANTASK: this.handleReportCleantask, - DEVICE_EVENT_REPORT_CLEANMAP: this.handleReportCleanmap, - DEVICE_CLEANMAP_BINDATA_REPORT_REQ: this.handleBinDataReport, - DEVICE_EVENT_REPORT_REQ: this.handleEventReport, - DEVICE_SETTIME_REQ: this.handleSetTime, - }; - - constructor({ device, user, multiplexer }: RobotProps) { - super(); - this.device = device; - this.user = user; - this.multiplexer = multiplexer; - this.debug = debug(__filename).extend(this.device.id.toString()); - this.debug('new robot'); - } - - get isConnected(): boolean { - return this.multiplexer.hasConnections; - } - - async start(): Promise { - if (this.device.hasMopAttached && this.device.mode?.value !== DeviceModeValue.Mop) { - await this.setMode(new DeviceMode(DeviceModeValue.Mop)); - } else if (!this.device.hasMopAttached && this.device.mode?.value === DeviceModeValue.Mop) { - await this.setMode(new DeviceMode(DeviceModeValue.None)); - } - - if (this.device.mode?.value === DeviceModeValue.Zone) { - await this.sendRecv('DEVICE_AREA_CLEAN_REQ', 'DEVICE_AREA_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.START, - }); - } else if (this.device.mode?.value === DeviceModeValue.Mop) { - await this.sendRecv('DEVICE_MOP_FLOOR_CLEAN_REQ', 'DEVICE_MOP_FLOOR_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.START, - }); - } else if (this.device.mode?.value === DeviceModeValue.Spot && this.device.map?.currentSpot) { - await this.sendRecv('DEVICE_MAPID_SET_NAVIGATION_REQ', 'DEVICE_MAPID_SET_NAVIGATION_RSP', { - mapHeadId: this.device.map.id.value, - poseX: this.device.map.currentSpot.x, - poseY: this.device.map.currentSpot.y, - posePhi: this.device.map.currentSpot.phi, - ctrlValue: CTRL_VALUE.START, - }); - } else { - if ( - this.device.system.supports(DeviceCapability.MAP_PLANS) && - this.device.state?.value === DeviceStateValue.Docked && - this.device.map - ) { - const { id, restrictedZones } = this.device.map; - - await this.sendRecv('DEVICE_MAPID_SET_PLAN_PARAMS_REQ', 'DEVICE_MAPID_SET_PLAN_PARAMS_RSP', { - mapHeadId: id.value, - // FIXME: this will change user map name. - mapName: 'Default', - planId: 2, - // FIXME: this will change user plan name. - planName: 'Default', - roomList: this.device.map.rooms.map((room) => ({ - roomId: room.id.value, - roomName: room.name, - enable: true, - })), - areaInfo: { - mapHeadId: id.value, - planId: 2, - cleanAreaLength: restrictedZones.length, - cleanAreaList: restrictedZones.map((zone) => ({ - cleanAreaId: zone.id.value, - type: 0, - coordinateLength: zone.coordinates.length, - coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), - })), - }, - }); - } - - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.START, - cleanType: 2, - }); - } - } - - async pause(): Promise { - if (this.device.mode?.value === DeviceModeValue.Zone) { - await this.sendRecv('DEVICE_AREA_CLEAN_REQ', 'DEVICE_AREA_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.PAUSE, - }); - } else if (this.device.mode?.value === DeviceModeValue.Mop) { - await this.sendRecv('DEVICE_MOP_FLOOR_CLEAN_REQ', 'DEVICE_MOP_FLOOR_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.PAUSE, - }); - } else if (this.device.mode?.value === DeviceModeValue.Spot && this.device.map?.currentSpot) { - await this.sendRecv('DEVICE_MAPID_SET_NAVIGATION_REQ', 'DEVICE_MAPID_SET_NAVIGATION_RSP', { - mapHeadId: this.device.map.id.value, - poseX: this.device.map.currentSpot.x, - poseY: this.device.map.currentSpot.y, - posePhi: this.device.map.currentSpot.phi, - ctrlValue: CTRL_VALUE.PAUSE, - }); - } else { - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.PAUSE, - cleanType: 2, - }); - } - } - - async stop(): Promise { - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.STOP, - cleanType: 2, - }); - - if (this.device.system.supports(DeviceCapability.MAP_PLANS) && this.device.map) { - const { id, restrictedZones } = this.device.map; - - await this.sendRecv('DEVICE_MAPID_SET_PLAN_PARAMS_REQ', 'DEVICE_MAPID_SET_PLAN_PARAMS_RSP', { - mapHeadId: id.value, - // FIXME: this will change user map name. - mapName: 'Default', - planId: 2, - // FIXME: this will change user plan name. - planName: 'Default', - roomList: this.device.map.rooms.map((room) => ({ - roomId: room.id.value, - roomName: room.name, - enable: true, - })), - areaInfo: { - mapHeadId: id.value, - planId: 2, - cleanAreaLength: restrictedZones.length, - cleanAreaList: restrictedZones.map((zone) => ({ - cleanAreaId: zone.id.value, - type: 0, - coordinateLength: zone.coordinates.length, - coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), - })), - }, - }); - - await this.updateMap(); - } - } - - async home(): Promise { - await this.sendRecv('DEVICE_CHARGE_REQ', 'DEVICE_CHARGE_RSP', { - enable: 1, - }); - } - - async locate(): Promise { - await this.sendRecv('DEVICE_SEEK_LOCATION_REQ', 'DEVICE_SEEK_LOCATION_RSP', {}); - } - - async setMode(mode: DeviceMode): Promise { - if (mode.value === DeviceModeValue.None) { - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.STOP, - cleanType: 2, - }); - } else if (mode.value === DeviceModeValue.Spot) { - let mask = 0x78ff | 0x200; - - if (!this.device.system.supports(DeviceCapability.MAP_PLANS)) { - mask = 0xff | 0x200; - } - - await this.sendRecv('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP', { mask }); - } else if (mode.value === DeviceModeValue.Zone) { - await this.sendRecv('DEVICE_AREA_CLEAN_REQ', 'DEVICE_AREA_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.STOP, - }); - - let mask = 0x78ff | 0x100; - - if (!this.device.system.supports(DeviceCapability.MAP_PLANS)) { - mask = 0xff | 0x100; - } - - await this.sendRecv('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP', { mask }); - } else if (mode.value === DeviceModeValue.Mop) { - await this.sendRecv('DEVICE_MAPID_INTO_MODEIDLE_INFO_REQ', 'DEVICE_MAPID_INTO_MODEIDLE_INFO_RSP', { - mode: 7, - }); - } else { - throw new ArgumentInvalidException('Unknown device mode'); - } - - await waitFor(() => this.device.mode?.value === mode.value, { - timeout: MODE_CHANGE_TIMEOUT, - }).catch(() => { - throw new DomainException(`Unable to change robot mode to ${mode.value}`); - }); - } - - async setFanSpeed(fanSpeed: DeviceFanSpeed): Promise { - await this.sendRecv('DEVICE_SET_CLEAN_PREFERENCE_REQ', 'DEVICE_SET_CLEAN_PREFERENCE_RSP', { - mode: deviceFanSpeedMapper.fromDomain(fanSpeed), - }); - } - - async setWaterLevel(waterLevel: DeviceWaterLevel): Promise { - await this.sendRecv('DEVICE_SET_CLEAN_PREFERENCE_REQ', 'DEVICE_SET_CLEAN_PREFERENCE_RSP', { - mode: deviceWaterLevelMapper.fromDomain(waterLevel), - }); - } - - async getTime(): Promise { - const packet = await this.sendRecv('DEVICE_GETTIME_REQ', 'DEVICE_GETTIME_RSP', {}); - const object = packet.payload.object; - - return { - timestamp: object.body.deviceTime * 1000, - offset: -1 * ((object.body.deviceTimezone || 0) / 60), - }; - } - - async getConsumables(): Promise { - if (!this.device.system.supports(DeviceCapability.CONSUMABLES)) { - return []; - } - - const packet = await this.sendRecv( - 'DEVICE_MAPID_GET_CONSUMABLES_PARAM_REQ', - 'DEVICE_MAPID_GET_CONSUMABLES_PARAM_RSP', - {}, - ); - const object = packet.payload.object; - const consumables = [ - new DeviceConsumable({ - type: DeviceConsumableType.MainBrush, - minutesUsed: object.mainBrushTime, - }), - new DeviceConsumable({ - type: DeviceConsumableType.SideBrush, - minutesUsed: object.sideBrushTime, - }), - new DeviceConsumable({ - type: DeviceConsumableType.Filter, - minutesUsed: object.filterTime, - }), - new DeviceConsumable({ - type: DeviceConsumableType.Dishcloth, - minutesUsed: object.dishclothTime, - }), - ]; - - this.device.updateConsumables(consumables); - - return consumables; - } - - async resetConsumable(consumable: DeviceConsumableType): Promise { - if (!(consumable in CONSUMABLE_TYPE_RESET)) { - throw new ArgumentInvalidException('Invalid consumable'); - } - - const itemId = CONSUMABLE_TYPE_RESET[consumable]; - - await this.sendRecv('DEVICE_MAPID_SET_CONSUMABLES_PARAM_REQ', 'DEVICE_MAPID_SET_CONSUMABLES_PARAM_RSP', { itemId }); - } - - async updateMap(): Promise { - let mask = 0x78ff; - - if (!this.device.system.supports(DeviceCapability.MAP_PLANS)) { - mask = 0xff; - } - - await this.sendRecv('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP', { mask }); - } - - async getWlan(): Promise { - const packet = await this.sendRecv('DEVICE_WLAN_INFO_GETTING_REQ', 'DEVICE_WLAN_INFO_GETTING_RSP', {}); - const object = packet.payload.object; - - this.device.updateWlan(new DeviceWlan(object.body)); - this.emit('updateDevice'); - - return this.device.wlan as DeviceWlan; - } - - async enterManualMode(): Promise { - // TODO: make a stop if robot is not stopped - await this.sendRecv('DEVICE_MANUAL_CTRL_REQ', 'DEVICE_MANUAL_CTRL_RSP', { - command: MANUAL_MODE.init, - }); - } - - async leaveManualMode(): Promise { - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.STOP, - cleanType: 2, - }); - } - - async setManualMode(command: ManualMode): Promise { - await this.sendRecv('DEVICE_MANUAL_CTRL_REQ', 'DEVICE_MANUAL_CTRL_RSP', { - command, - }); - } - - async getOrders(): Promise { - const packet = await this.sendRecv('DEVICE_ORDERLIST_GETTING_REQ', 'DEVICE_ORDERLIST_GETTING_RSP', {}); - const object = packet.payload.object; - const orders = object.orderList?.map(deviceOrderMapper.toDomain) || []; - - this.device.updateOrders(orders); - - return orders; - } - - async setOrder(order: DeviceOrder): Promise { - const orderList = deviceOrderMapper.fromDomain(order); - - await this.sendRecv('DEVICE_ORDERLIST_SETTING_REQ', 'DEVICE_ORDERLIST_SETTING_RSP', orderList); - } - - async deleteOrder(order: DeviceOrder): Promise { - await this.sendRecv('DEVICE_ORDERLIST_DELETE_REQ', 'DEVICE_ORDERLIST_DELETE_RSP', { - orderId: order.id.value, - mode: 1, - }); - } - - async cleanPosition(position: MapPosition): Promise { - if (!this.device.map) { - throw new DomainException('Unable to set robot position: map not loaded'); - } - - if (this.device.mode?.value !== DeviceModeValue.Spot) { - await this.setMode(new DeviceMode(DeviceModeValue.Spot)); - } - - await this.sendRecv('DEVICE_MAPID_SET_NAVIGATION_REQ', 'DEVICE_MAPID_SET_NAVIGATION_RSP', { - mapHeadId: this.device.map.id.value, - poseX: position.x, - poseY: position.y, - posePhi: position.phi, - ctrlValue: CTRL_VALUE.START, - }); - } - - /** - * A ┌───┐ D - * │ │ - * B └───┘ C - */ - async cleanAreas(areas: MapCoordinate[][]): Promise { - if (!this.device.map) { - return; - } - - if (this.device.mode?.value !== DeviceModeValue.Zone) { - await this.setMode(new DeviceMode(DeviceModeValue.Zone)); - } - - const req = { - mapHeadId: this.device.map.id.value, - planId: 0, - cleanAreaLength: areas.length, - cleanAreaList: areas.map((coords) => { - return { - cleanAreaId: ID.generate().value, - type: 0, - coordinateLength: coords.length, - coordinateList: coords, - }; - }), - }; - - await this.sendRecv('DEVICE_MAPID_SET_AREA_CLEAN_INFO_REQ', 'DEVICE_MAPID_SET_AREA_CLEAN_INFO_RSP', req); - await this.sendRecv('DEVICE_AREA_CLEAN_REQ', 'DEVICE_AREA_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.START, - }); - } - - /** - * A ┌───┐ D - * │ │ - * B └───┘ C - */ - async setRestrictedZones(areas: MapCoordinate[][]): Promise { - if (!this.device.map) { - return; - } - - if (!areas.length) { - areas.push([]); - } - - if (!this.device.system.supports(DeviceCapability.MAP_PLANS)) { - await this.sendRecv('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP', { - mask: 0xff | 0x400, - }); - } - - const cleanAreaList = [ - ...areas.map((coords) => ({ - cleanAreaId: ID.generate().value, - type: 0, - coordinateLength: coords.length, - coordinateList: coords, - })), - ...this.device.map.restrictedZones.map((zone) => ({ - cleanAreaId: zone.id.value, - type: 0, - coordinateLength: 0, - coordinateList: [], - })), - ]; - - await this.sendRecv('DEVICE_MAPID_SET_AREA_RESTRICTED_INFO_REQ', 'DEVICE_MAPID_SET_AREA_RESTRICTED_INFO_RSP', { - mapHeadId: this.device.map.id.value, - planId: 0, - cleanAreaLength: cleanAreaList.length, - cleanAreaList, - }); - } - - async getQuietHours(): Promise { - const packet = await this.sendRecv('USER_GET_DEVICE_QUIETHOURS_REQ', 'USER_GET_DEVICE_QUIETHOURS_RSP', {}); - const object = packet.payload.object; - const quietHours = new QuietHoursSetting({ - isEnabled: object.isOpen, - beginTime: DeviceTime.fromMinutes(object.beginTime), - endTime: DeviceTime.fromMinutes(object.endTime), - }); - - this.device.updateConfig(this.device.config?.clone({ quietHours })); - - return quietHours; - } - - async setQuietHours(quietHours: QuietHoursSetting): Promise { - await this.sendRecv('USER_SET_DEVICE_QUIETHOURS_REQ', 'USER_SET_DEVICE_QUIETHOURS_RSP', { - isOpen: quietHours.isEnabled, - beginTime: quietHours.beginTime.toMinutes(), - endTime: quietHours.endTime.toMinutes(), - }); - - this.device.updateConfig(this.device.config?.clone({ quietHours })); - } - - async setCarpetMode(enable: boolean): Promise { - await this.sendRecv('USER_SET_DEVICE_CLEANPREFERENCE_REQ', 'USER_SET_DEVICE_CLEANPREFERENCE_RSP', { - carpetTurbo: enable, - }); - - this.device.updateConfig(this.device.config?.clone({ carpetMode: new DeviceSetting({ isEnabled: enable }) })); - this.emit('updateDevice'); - } - - async setHistoryMap(enable: boolean): Promise { - await this.sendRecv('USER_SET_DEVICE_CLEANPREFERENCE_REQ', 'USER_SET_DEVICE_CLEANPREFERENCE_RSP', { - historyMap: enable, - }); - - this.device.updateConfig(this.device.config?.clone({ historyMap: new DeviceSetting({ isEnabled: enable }) })); - this.emit('updateDevice'); - } - - async setVoice(voice: VoiceSetting): Promise { - const robotVoice = deviceVoiceMapper.fromDomain(voice); - - await this.sendRecv('USER_SET_DEVICE_CTRL_SETTING_REQ', 'USER_SET_DEVICE_CTRL_SETTING_RSP', { - voiceMode: robotVoice.isEnabled, - volume: robotVoice.volume, - }); - - this.device.updateConfig(this.device.config?.clone({ voice })); - this.emit('updateDevice'); - } - - async saveWaitingMap(save: boolean): Promise { - await this.sendRecv('DEVICE_MAPID_SET_SAVEWAITINGMAP_INFO_REQ', 'DEVICE_MAPID_SET_SAVEWAITINGMAP_INFO_RSP', { - mode: Number(save), - }); - - this.device.updateHasWaitingMap(false); - } - - async updateRoom(room: Room): Promise { - if (!this.device.map) { - return; - } - - const { id, restrictedZones } = this.device.map; - - await this.sendRecv('DEVICE_MAPID_SET_PLAN_PARAMS_REQ', 'DEVICE_MAPID_SET_PLAN_PARAMS_RSP', { - mapHeadId: id.value, - // FIXME: this will change user map name. - mapName: 'Default', - planId: 2, - // FIXME: this will change user plan name. - planName: 'Default', - roomList: this.device.map.rooms.map((r) => { - r = room.equals(r) ? room : r; - - return { - roomId: r.id.value, - roomName: r.name, - enable: true, - }; - }), - areaInfo: { - mapHeadId: id.value, - planId: 2, - cleanAreaLength: restrictedZones.length, - cleanAreaList: restrictedZones.map((zone) => ({ - cleanAreaId: zone.id.value, - type: 0, - coordinateLength: zone.coordinates.length, - coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), - })), - }, - }); - } - - async joinRooms(rooms: Room[]): Promise { - if (!this.device.map) { - return; - } - - const stream = new BufferWriter(); - - writeByte(stream, 1); - writeByte(stream, rooms.length); - - rooms.forEach((room) => { - writeByte(stream, room.id.value); - }); - - const data = stream.buffer; - - await this.sendRecv('DEVICE_MAPID_SET_ARRANGEROOM_INFO_REQ', 'DEVICE_MAPID_SET_ARRANGEROOM_INFO_RSP', { - mapHeadId: this.device.map.id.value, - type: 0, - dataLen: data.length, - data, - roomId: 0, - }); - } - - async splitRoom(room: Room, pointA: MapCoordinate, pointB: MapCoordinate): Promise { - if (!this.device.map) { - return; - } - - const stream = new BufferWriter(); - - writeByte(stream, 1); - writeByte(stream, 1); - writeByte(stream, 2); - writeFloat(stream, pointA.x); - writeFloat(stream, pointA.y); - writeFloat(stream, pointB.x); - writeFloat(stream, pointB.y); - - const data = stream.buffer; - - await this.sendRecv('DEVICE_MAPID_SET_ARRANGEROOM_INFO_REQ', 'DEVICE_MAPID_SET_ARRANGEROOM_INFO_RSP', { - mapHeadId: this.device.map.id.value, - type: 1, - dataLen: data.length, - data, - roomId: room.id.value, - }); - } - - async cleanRooms(rooms: Room[]): Promise { - if (!this.device.map) { - return; - } - - const { id, restrictedZones } = this.device.map; - - await this.sendRecv('DEVICE_MAPID_SET_PLAN_PARAMS_REQ', 'DEVICE_MAPID_SET_PLAN_PARAMS_RSP', { - mapHeadId: id.value, - // FIXME: this will change user map name. - mapName: 'Default', - planId: 2, - // FIXME: this will change user plan name. - planName: 'Default', - roomList: this.device.map.rooms.map((room) => ({ - roomId: room.id.value, - roomName: room.name, - enable: Boolean(rooms.find((r) => room.equals(r))), - })), - areaInfo: { - mapHeadId: id.value, - planId: 2, - cleanAreaLength: restrictedZones.length, - cleanAreaList: restrictedZones.map((zone) => ({ - cleanAreaId: zone.id.value, - type: 0, - coordinateLength: zone.coordinates.length, - coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), - })), - }, - }); - - await this.sendRecv('DEVICE_MAPID_SELECT_MAP_PLAN_REQ', 'DEVICE_MAPID_SELECT_MAP_PLAN_RSP', { - mapHeadId: id.value, - planId: 2, - mode: 1, - }); - - await this.sendRecv('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP', { mask: 0x78ff }); - - await this.sendRecv('DEVICE_AUTO_CLEAN_REQ', 'DEVICE_AUTO_CLEAN_RSP', { - ctrlValue: CTRL_VALUE.START, - cleanType: 2, - }); - } - - async resetMap(): Promise { - await this.sendRecv('DEVICE_MAPID_SET_HISTORY_MAP_ENABLE_REQ', 'DEVICE_MAPID_SET_HISTORY_MAP_ENABLE_RSP', {}); - } - - async controlLock(): Promise { - await this.sendRecv('DEVICE_CONTROL_LOCK_REQ', 'DEVICE_CONTROL_LOCK_RSP', {}); - } - - async handshake(): Promise { - await this.controlLock(); - - this.send('DEVICE_STATUS_GETTING_REQ', {}); - - void this.send('DEVICE_GET_ALL_GLOBAL_MAP_INFO_REQ', { - unk1: 0, - unk2: '', - }); - - void this.getTime(); - void this.updateMap(); - void this.getWlan(); - } - - @bind - handleDeviceVersionInfoUpdate(message: Message<'DEVICE_VERSION_INFO_UPDATE_REQ'>): void { - const object = message.packet.payload.object; - - this.device.updateVersion( - new DeviceVersion({ - software: object.softwareVersion, - hardware: object.hardwareVersion, - }), - ); - this.emit('updateDevice'); - - message.respond('DEVICE_VERSION_INFO_UPDATE_RSP', { - result: 0, - }); - } - - @bind - handleDeviceAgentSetting(message: Message<'PUSH_DEVICE_AGENT_SETTING_REQ'>): void { - const object = message.packet.payload.object; - const props: DeviceSettingsProps = { - voice: deviceVoiceMapper.toDomain({ - isEnabled: object.voice.voiceMode, - volume: object.voice.volume, - }), - quietHours: new QuietHoursSetting({ - isEnabled: object.quietHours.isOpen, - beginTime: DeviceTime.fromMinutes(object.quietHours.beginTime), - endTime: DeviceTime.fromMinutes(object.quietHours.endTime), - }), - ecoMode: new DeviceSetting({ isEnabled: object.cleanPreference.ecoMode ?? false }), - repeatClean: new DeviceSetting({ isEnabled: object.cleanPreference.repeatClean ?? false }), - brokenClean: new DeviceSetting({ isEnabled: object.cleanPreference.cleanBroken ?? false }), - carpetMode: new DeviceSetting({ isEnabled: object.cleanPreference.carpetTurbo ?? false }), - historyMap: new DeviceSetting({ isEnabled: object.cleanPreference.historyMap ?? false }), - }; - - this.device.updateConfig(new DeviceSettings(props)); - - message.respond('PUSH_DEVICE_AGENT_SETTING_RSP', { - result: 0, - }); - } - - @bind - handleClientHeartbeat(message: Message<'CLIENT_HEARTBEAT_REQ'>): void { - message.respond('CLIENT_HEARTBEAT_RSP', {}); - } - - @bind - handleDevicePackageUpgrade(message: Message<'PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'>): void { - message.respond('PUSH_DEVICE_PACKAGE_UPGRADE_INFO_RSP', { - result: 0, - }); - } - - @bind - handleDeviceStatus(message: Message<'DEVICE_MAPID_WORK_STATUS_PUSH_REQ'>): void { - const object = message.packet.payload.object; - const { battery, type, workMode, chargeStatus, cleanPreference, faultCode, waterLevel, mopType } = object; - - this.device.updateCurrentClean( - new DeviceCleanWork({ - size: new CleanSize(object.cleanSize), - time: DeviceTime.fromMinutes(object.cleanTime), - }), - ); - this.device.updateState(deviceStateMapper.toDomain({ type, workMode, chargeStatus })); - this.device.updateMode(deviceModeMapper.toDomain(workMode)); - this.device.updateError(deviceErrorMapper.toDomain(faultCode)); - this.device.updateBattery(deviceBatteryMapper.toDomain(battery)); - this.device.updateFanSpeed(deviceFanSpeedMapper.toDomain(cleanPreference)); - - if (isPresent(mopType)) { - this.device.updateHasMopAttached(mopType); - } - - if (isPresent(waterLevel)) { - this.device.updateWaterLevel(deviceWaterLevelMapper.toDomain(waterLevel)); - } - - this.emit('updateDevice'); - } - - @bind - handleMapUpdate(message: Message<'DEVICE_MAPID_PUSH_MAP_INFO' | 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP'>): void { - const object = message.packet.payload.object; - const { - statusInfo, - mapHeadInfo, - mapGrid, - historyHeadInfo, - robotPoseInfo, - robotChargeInfo, - cleanRoomList, - roomSegmentList, - wallListInfo, - spotInfo, - cleanPlanList, - currentPlanId, - } = object; - - if (statusInfo) { - const { - batteryPercent: battery, - faultType: type, - workingMode: workMode, - chargeState: chargeStatus, - cleanPreference, - faultCode, - } = statusInfo; - - this.device.updateCurrentClean( - new DeviceCleanWork({ - size: new CleanSize(statusInfo.cleanSize), - time: DeviceTime.fromMinutes(statusInfo.cleanTime), - }), - ); - this.device.updateBattery(deviceBatteryMapper.toDomain(battery)); - this.device.updateMode(deviceModeMapper.toDomain(workMode)); - this.device.updateState(deviceStateMapper.toDomain({ type, workMode, chargeStatus })); - this.device.updateError(deviceErrorMapper.toDomain(faultCode)); - this.device.updateFanSpeed(deviceFanSpeedMapper.toDomain(cleanPreference)); - this.emit('updateDevice'); - } - - let map = this.device.map; - - if (mapHeadInfo && mapGrid) { - const props = { - id: new ID(mapHeadInfo.mapHeadId), - size: new MapPixel({ - x: mapHeadInfo.sizeX, - y: mapHeadInfo.sizeY, - }), - min: new MapCoordinate({ - x: mapHeadInfo.minX, - y: mapHeadInfo.minY, - }), - max: new MapCoordinate({ - x: mapHeadInfo.maxX, - y: mapHeadInfo.maxY, - }), - resolution: mapHeadInfo.resolution, - grid: mapGrid, - rooms: [], - restrictedZones: [], - robotPath: [], - }; - - map = map ? map.clone(props) : new DeviceMap(props); - - this.device.updateMap(map); - } - - if (map) { - if (historyHeadInfo) { - const currentIndex = map.robotPath.length; - - map.updateRobotPath( - map.robotPath.concat( - historyHeadInfo.pointList.slice(currentIndex).map(({ x, y }) => new MapCoordinate({ x, y })), - ), - ); - } - - if (robotPoseInfo) { - map.updateRobot( - new MapPosition({ - x: robotPoseInfo.poseX, - y: robotPoseInfo.poseY, - phi: robotPoseInfo.posePhi, - }), - ); - } - - if (robotChargeInfo) { - map.updateCharger( - new MapPosition({ - x: robotChargeInfo.poseX, - y: robotChargeInfo.poseY, - phi: robotChargeInfo.posePhi, - }), - ); - } - - if (spotInfo) { - map.updateCurrentSpot( - new MapPosition({ - x: spotInfo.poseX, - y: spotInfo.poseY, - phi: spotInfo.posePhi, - }), - ); - } - - if (wallListInfo) { - map.updateRestrictedZones( - wallListInfo.cleanAreaList.map((cleanArea) => { - return new Zone({ - id: new ID(cleanArea.cleanAreaId), - coordinates: cleanArea.coordinateList.map(({ x, y }) => { - return new MapCoordinate({ - x, - y, - }); - }), - }); - }), - ); - } - - if (cleanRoomList && roomSegmentList && cleanPlanList) { - const currentPlan = cleanPlanList.find((plan) => plan.planId === currentPlanId); - - map.updateRooms( - cleanRoomList - .map((cleanRoom) => { - const segment = roomSegmentList.find((roomSegment) => roomSegment.roomId === cleanRoom.roomId); - const roomInfo = currentPlan?.cleanRoomInfoList.find((r) => r.roomId === cleanRoom.roomId); - - if (!segment) { - return undefined; - } - - return new Room({ - id: new ID(cleanRoom.roomId), - name: cleanRoom.roomName, - isEnabled: Boolean(roomInfo?.enable), - center: new MapCoordinate({ - x: cleanRoom.roomX, - y: cleanRoom.roomY, - }), - pixels: segment?.roomPixelList.map((pixel) => { - return new MapPixel({ - x: pixel.x, - y: pixel.y, - }); - }), - }); - }) - .filter(isPresent), - ); - } - } - - this.emit('updateMap'); - } - - @bind - handleUpdateRobotPosition(message: Message<'DEVICE_MAPID_PUSH_POSITION_INFO'>): void { - if (!this.device.map) { - return; - } - - const object = message.packet.payload.object; - - if (object.update) { - this.device.map.updateRobot( - new MapPosition({ - x: object.poseX, - y: object.poseY, - phi: object.posePhi, - }), - ); - - this.emit('updateRobotPosition'); - } - } - - @bind - handleUpdateChargePosition(message: Message<'DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'>): void { - if (!this.device.map) { - return; - } - - const object = message.packet.payload.object; - - this.device.map.updateCharger( - new MapPosition({ - x: object.poseX, - y: object.poseY, - phi: object.posePhi, - }), - ); - this.emit('updateChargerPosition'); - } - - @bind - handleDeviceBatteryInfo(message: Message<'PUSH_DEVICE_BATTERY_INFO_REQ'>): void { - message.respond('PUSH_DEVICE_BATTERY_INFO_RSP', { - result: 0, - }); - - const object = message.packet.payload.object; - - this.device.updateBattery(deviceBatteryMapper.toDomain(object.battery.level)); - - this.emit('updateDevice'); - } - - @bind - handleWaitingMap(): void { - this.device.updateHasWaitingMap(true); - this.emit('updateDevice'); - } - - @bind - handleWorkstatusReport(message: Message<'DEVICE_WORKSTATUS_REPORT_REQ'>): void { - message.respond('DEVICE_WORKSTATUS_REPORT_RSP', { - result: 0, - }); - } - - @bind - handleReportCleantask(message: Message<'DEVICE_EVENT_REPORT_CLEANTASK'>): void { - message.respond('UNK_11A4', { unk1: 0 }); - } - - @bind - handleReportCleanmap(message: Message<'DEVICE_EVENT_REPORT_CLEANMAP'>): void { - const object = message.packet.payload.object; - message.respond('DEVICE_EVENT_REPORT_RSP', { - result: 0, - body: { - cleanId: object.cleanId, - }, - }); - } - - @bind - handleBinDataReport(message: Message<'DEVICE_CLEANMAP_BINDATA_REPORT_REQ'>): void { - const object = message.packet.payload.object; - message.respond('DEVICE_CLEANMAP_BINDATA_REPORT_RSP', { - result: 0, - cleanId: object.cleanId, - }); - } - - @bind - handleEventReport(message: Message<'DEVICE_EVENT_REPORT_REQ'>): void { - message.respond('UNK_11A7', { unk1: 0 }); - } - - @bind - handleSetTime(message: Message<'DEVICE_SETTIME_REQ'>): void { - const date = new Date(); - - message.respond('DEVICE_SETTIME_RSP', { - deviceTime: Math.floor(date.getTime() / 1000), - deviceTimezone: -1 * (date.getTimezoneOffset() * 60), - }); - } - - addConnection(connection: Connection): void { - const added = this.multiplexer.addConnection(connection); - - if (added && this.multiplexer.connectionCount === 2) { - void this.handshake(); - } - } - - handleMessage(message: Message): void { - const handler = this.handlers[message.opname]; - - if (message.packet.userId.value !== 0 && message.packet.userId.value !== this.user.id.value) { - message.respond('COMMON_ERROR_REPLY', { - result: 11001, - error: 'Target user is offline', - opcode: message.packet.payload.opcode.code, - }); - return; - } - - if (handler) { - handler(message); - } else { - this.debug(`unhandled opcode ${message.opname}`); - } - } - - override toString(): string { - return [`device: ${this.device.toString()}`, `user: ${this.user.toString()}`].join(' '); - } - - disconnect(): void { - this.debug('disconnecting...'); - - return this.multiplexer.close(); - } - - send(opname: Name, object: PayloadObjectFrom): void { - const ret = this.multiplexer.send({ - opname, - userId: this.user.id, - deviceId: this.device.id, - object, - }); - - if (!ret) { - throw new DomainException(`There was an error sending opcode '${opname}'`); - } - } - - recv(opname: Name): Promise> { - return new Promise((resolve, reject) => { - const done = (packet: Packet) => { - clearTimeout(timer); - resolve(packet as Packet); - }; - - const fail = () => { - this.multiplexer.off(opname, done); - reject(new DomainException(`Timeout waiting for response from opcode '${opname}'`)); - }; - - const timer = setTimeout(fail, RECV_TIMEOUT); - - this.multiplexer.once(opname, done); - }); - } - - sendRecv( - sendOPName: SendName, - recvOPName: RecvName, - sendObject: PayloadObjectFrom, - ): Promise> { - this.send(sendOPName, sendObject); - - return this.recv(recvOPName); - } -} diff --git a/packages/adapter-tcp/src/event-buses/packet.event-bus.test.ts b/packages/adapter-tcp/src/event-buses/packet.event-bus.test.ts new file mode 100644 index 00000000..b7128d90 --- /dev/null +++ b/packages/adapter-tcp/src/event-buses/packet.event-bus.test.ts @@ -0,0 +1,11 @@ +import { EventBus } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { PacketEventBus } from './packet.event-bus'; + +describe('PacketEventBus', function () { + it('should be created', function () { + const packetEventBus = new PacketEventBus(); + + expect(packetEventBus).to.be.instanceOf(EventBus); + }); +}); diff --git a/packages/adapter-tcp/src/event-buses/packet.event-bus.ts b/packages/adapter-tcp/src/event-buses/packet.event-bus.ts new file mode 100644 index 00000000..580e8c34 --- /dev/null +++ b/packages/adapter-tcp/src/event-buses/packet.event-bus.ts @@ -0,0 +1,13 @@ +import { EventBus } from '@agnoc/toolkit'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PayloadDataName } from '@agnoc/transport-tcp'; + +/** Events for the packet event bus. */ +export type PacketEventBusEvents = { + [Name in PayloadDataName]: PacketMessage; +} & { + [key: string]: PacketMessage; +}; + +/** Event bus for packets. */ +export class PacketEventBus extends EventBus {} diff --git a/packages/adapter-tcp/src/factories/connection.factory.test.ts b/packages/adapter-tcp/src/factories/connection.factory.test.ts new file mode 100644 index 00000000..deae0074 --- /dev/null +++ b/packages/adapter-tcp/src/factories/connection.factory.test.ts @@ -0,0 +1,32 @@ +import { Connection } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { PacketSocket } from '@agnoc/transport-tcp'; +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { PacketConnectionFactory } from './connection.factory'; +import type { PacketEventBus } from '../event-buses/packet.event-bus'; +import type { PacketFactory, PacketMapper } from '@agnoc/transport-tcp'; + +describe('PacketConnectionFactory', function () { + let packetEventBus: PacketEventBus; + let packetFactory: PacketFactory; + let packetMapper: PacketMapper; + let connectionFactory: PacketConnectionFactory; + + beforeEach(function () { + packetEventBus = imock(); + packetFactory = imock(); + packetMapper = imock(); + connectionFactory = new PacketConnectionFactory(instance(packetEventBus), instance(packetFactory)); + }); + + describe('#create()', function () { + it('should return a PacketConnection', function () { + const id = ID.generate(); + const connection = connectionFactory.create({ id, socket: new PacketSocket(packetMapper) }); + + expect(connection).to.be.instanceOf(Connection); + expect(connection.id.equals(id)).to.be.true; + }); + }); +}); diff --git a/packages/adapter-tcp/src/factories/connection.factory.ts b/packages/adapter-tcp/src/factories/connection.factory.ts new file mode 100644 index 00000000..077963e0 --- /dev/null +++ b/packages/adapter-tcp/src/factories/connection.factory.ts @@ -0,0 +1,13 @@ +import { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketConnectionProps } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketEventBus } from '../event-buses/packet.event-bus'; +import type { Factory } from '@agnoc/toolkit'; +import type { PacketFactory } from '@agnoc/transport-tcp'; + +export class PacketConnectionFactory implements Factory { + constructor(private readonly packetEventBus: PacketEventBus, private readonly packetFactory: PacketFactory) {} + + create(props: PacketConnectionProps): PacketConnection { + return new PacketConnection(this.packetFactory, this.packetEventBus, props); + } +} diff --git a/packages/adapter-tcp/src/index.ts b/packages/adapter-tcp/src/index.ts index 707f9c1b..d73e748b 100644 --- a/packages/adapter-tcp/src/index.ts +++ b/packages/adapter-tcp/src/index.ts @@ -1,13 +1 @@ -export * from './emitters/cloud-server.emitter'; -export * from './emitters/connection.emitter'; -export * from './emitters/multiplexer.emitter'; -export * from './emitters/robot.emitter'; -export * from './mappers/device-battery.mapper'; -export * from './mappers/device-error.mapper'; -export * from './mappers/device-fan-speed.mapper'; -export * from './mappers/device-mode.mapper'; -export * from './mappers/device-order.mapper'; -export * from './mappers/device-state.mapper'; -export * from './mappers/device-voice.mapper'; -export * from './mappers/device-water-level.mapper'; -export * from './value-objects/message.value-object'; +export * from './tcp.server'; diff --git a/packages/adapter-tcp/src/mappers/clean-mode.mapper.test.ts b/packages/adapter-tcp/src/mappers/clean-mode.mapper.test.ts new file mode 100644 index 00000000..dcc8db5d --- /dev/null +++ b/packages/adapter-tcp/src/mappers/clean-mode.mapper.test.ts @@ -0,0 +1,29 @@ +import { CleanMode, CleanModeValue } from '@agnoc/domain'; +import { expect } from 'chai'; +import { CleanModeMapper } from './clean-mode.mapper'; + +describe('CleanModeMapper', function () { + let mapper: CleanModeMapper; + + beforeEach(function () { + mapper = new CleanModeMapper(); + }); + + describe('#toDomain()', function () { + it('should return a CleanMode', function () { + const cleanMode = mapper.toDomain(1); + + expect(cleanMode).to.be.instanceOf(CleanMode); + expect(cleanMode.value).to.be.equal(CleanModeValue.Auto); + }); + }); + + describe('#fromDomain()', function () { + it('should return a number', function () { + const cleanMode = new CleanMode(CleanModeValue.Auto); + const cleanModeValue = mapper.fromDomain(cleanMode); + + expect(cleanModeValue).to.be.equal(1); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-battery.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-battery.mapper.test.ts new file mode 100644 index 00000000..a4cfb1ba --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-battery.mapper.test.ts @@ -0,0 +1,43 @@ +import { DeviceBattery } from '@agnoc/domain'; +import { expect } from 'chai'; +import { DeviceBatteryMapper } from './device-battery.mapper'; + +describe('DeviceBatteryMapper', function () { + let mapper: DeviceBatteryMapper; + + beforeEach(function () { + mapper = new DeviceBatteryMapper(); + }); + + describe('#toDomain()', function () { + it('should return a DeviceBattery', function () { + const deviceBattery = mapper.toDomain(150); + + expect(deviceBattery).to.be.instanceOf(DeviceBattery); + expect(deviceBattery.value).to.be.equal(50); + }); + + it('should return a DeviceBattery with minimum value when below minimum', function () { + const deviceBattery = mapper.toDomain(-50); + + expect(deviceBattery).to.be.instanceOf(DeviceBattery); + expect(deviceBattery.value).to.be.equal(0); + }); + + it('should return a DeviceBattery with maximum value when above maximum', function () { + const deviceBattery = mapper.toDomain(250); + + expect(deviceBattery).to.be.instanceOf(DeviceBattery); + expect(deviceBattery.value).to.be.equal(100); + }); + }); + + describe('#fromDomain()', function () { + it('should return a number', function () { + const deviceBattery = new DeviceBattery(50); + const deviceBatteryValue = mapper.fromDomain(deviceBattery); + + expect(deviceBatteryValue).to.be.equal(150); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-error.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-error.mapper.test.ts new file mode 100644 index 00000000..d11f46f4 --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-error.mapper.test.ts @@ -0,0 +1,31 @@ +import { DeviceError, DeviceErrorValue } from '@agnoc/domain'; +import { DomainException, NotImplementedException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceErrorMapper } from './device-error.mapper'; + +describe('DeviceErrorMapper', function () { + let mapper: DeviceErrorMapper; + + beforeEach(function () { + mapper = new DeviceErrorMapper(); + }); + + describe('#toDomain()', function () { + it('should return a DeviceError', function () { + const deviceError = mapper.toDomain(0); + + expect(deviceError).to.be.instanceOf(DeviceError); + expect(deviceError.value).to.be.equal(DeviceErrorValue.None); + }); + + it('should throw an error when device mode is unknown', function () { + expect(() => mapper.toDomain(999)).to.throw(DomainException, `Unable to map error code '999' to domain value`); + }); + }); + + describe('#fromDomain()', function () { + it('should throw an error', function () { + expect(() => mapper.fromDomain()).to.throw(NotImplementedException, 'DeviceErrorMapper.fromDomain'); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-error.mapper.ts b/packages/adapter-tcp/src/mappers/device-error.mapper.ts index acd8f77f..895dbaa8 100644 --- a/packages/adapter-tcp/src/mappers/device-error.mapper.ts +++ b/packages/adapter-tcp/src/mappers/device-error.mapper.ts @@ -2,7 +2,7 @@ import { DeviceError, DeviceErrorValue } from '@agnoc/domain'; import { DomainException, NotImplementedException } from '@agnoc/toolkit'; import type { Mapper } from '@agnoc/toolkit'; -const ROBOT_TO_DOMAIN = { +const ROBOT_TO_DOMAIN: Record = { 0: DeviceErrorValue.None, 2003: DeviceErrorValue.LowPowerPlanDis, 2100: DeviceErrorValue.BrokenGoHome, @@ -53,13 +53,12 @@ export class DeviceErrorMapper implements Mapper { throw new DomainException(`Unable to map error code '${error}' to domain value`); } - const value = ROBOT_TO_DOMAIN[error as keyof typeof ROBOT_TO_DOMAIN]; + const value = ROBOT_TO_DOMAIN[error]; - // @ts-expect-error unknown error - return new DeviceError({ value }); + return new DeviceError(value); } fromDomain(): never { - throw new NotImplementedException('DeviceErrorMapper.toRobot'); + throw new NotImplementedException('DeviceErrorMapper.fromDomain'); } } diff --git a/packages/adapter-tcp/src/mappers/device-fan-speed.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-fan-speed.mapper.test.ts new file mode 100644 index 00000000..751e7284 --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-fan-speed.mapper.test.ts @@ -0,0 +1,29 @@ +import { DeviceFanSpeed, DeviceFanSpeedValue } from '@agnoc/domain'; +import { expect } from 'chai'; +import { DeviceFanSpeedMapper } from './device-fan-speed.mapper'; + +describe('DeviceFanSpeedMapper', function () { + let mapper: DeviceFanSpeedMapper; + + beforeEach(function () { + mapper = new DeviceFanSpeedMapper(); + }); + + describe('#toDomain()', function () { + it('should return a DeviceFanSpeed', function () { + const deviceFanSpeed = mapper.toDomain(0); + + expect(deviceFanSpeed).to.be.instanceOf(DeviceFanSpeed); + expect(deviceFanSpeed.value).to.be.equal(DeviceFanSpeedValue.Off); + }); + }); + + describe('#fromDomain()', function () { + it('should return a number', function () { + const deviceFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Off); + const deviceFanSpeedValue = mapper.fromDomain(deviceFanSpeed); + + expect(deviceFanSpeedValue).to.be.equal(0); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-mode.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-mode.mapper.test.ts new file mode 100644 index 00000000..a34e965e --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-mode.mapper.test.ts @@ -0,0 +1,52 @@ +import { DeviceMode, DeviceModeValue } from '@agnoc/domain'; +import { DomainException, NotImplementedException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceModeMapper } from './device-mode.mapper'; + +describe('DeviceModeMapper', function () { + let mapper: DeviceModeMapper; + + beforeEach(function () { + mapper = new DeviceModeMapper(); + }); + + describe('#toDomain()', function () { + it('should return a none device mode', function () { + const deviceMode = mapper.toDomain(0); + + expect(deviceMode).to.be.instanceOf(DeviceMode); + expect(deviceMode.value).to.be.equal(DeviceModeValue.None); + }); + + it('should return a zone device mode', function () { + const deviceMode = mapper.toDomain(30); + + expect(deviceMode).to.be.instanceOf(DeviceMode); + expect(deviceMode.value).to.be.equal(DeviceModeValue.Zone); + }); + + it('should return an spot device mode', function () { + const deviceMode = mapper.toDomain(7); + + expect(deviceMode).to.be.instanceOf(DeviceMode); + expect(deviceMode.value).to.be.equal(DeviceModeValue.Spot); + }); + + it('should return a mop device mode', function () { + const deviceMode = mapper.toDomain(36); + + expect(deviceMode).to.be.instanceOf(DeviceMode); + expect(deviceMode.value).to.be.equal(DeviceModeValue.Mop); + }); + + it('should throw an error when device mode is unknown', function () { + expect(() => mapper.toDomain(999)).to.throw(DomainException, 'Unable to map device mode from mode 999'); + }); + }); + + describe('#fromDomain()', function () { + it('should throw an error', function () { + expect(() => mapper.fromDomain()).to.throw(NotImplementedException, 'DeviceModeMapper.fromDomain'); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-mode.mapper.ts b/packages/adapter-tcp/src/mappers/device-mode.mapper.ts index 282dd560..3a55478d 100644 --- a/packages/adapter-tcp/src/mappers/device-mode.mapper.ts +++ b/packages/adapter-tcp/src/mappers/device-mode.mapper.ts @@ -28,6 +28,6 @@ export class DeviceModeMapper implements Mapper { } fromDomain(): number { - throw new NotImplementedException('DeviceModeMapper.toRobot'); + throw new NotImplementedException(`${this.constructor.name}.fromDomain`); } } diff --git a/packages/adapter-tcp/src/mappers/device-order.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-order.mapper.test.ts new file mode 100644 index 00000000..d64d74d0 --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-order.mapper.test.ts @@ -0,0 +1,179 @@ +import { + DeviceFanSpeed, + DeviceFanSpeedValue, + DeviceWaterLevel, + DeviceWaterLevelValue, + CleanMode, + CleanModeValue, + WeekDay, + WeekDayValue, + DeviceOrder, + DeviceTime, +} from '@agnoc/domain'; +import { ArgumentNotProvidedException, ID } from '@agnoc/toolkit'; +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceOrderMapper } from './device-order.mapper'; +import type { CleanModeMapper } from './clean-mode.mapper'; +import type { DeviceFanSpeedMapper } from './device-fan-speed.mapper'; +import type { DeviceWaterLevelMapper } from './device-water-level.mapper'; +import type { WeekDayListMapper } from './week-day-list.mapper'; + +describe('DeviceOrderMapper', function () { + let deviceFanSpeedMapper: DeviceFanSpeedMapper; + let deviceWaterLevelMapper: DeviceWaterLevelMapper; + let cleanModeMapper: CleanModeMapper; + let weekDayListMapper: WeekDayListMapper; + let mapper: DeviceOrderMapper; + + beforeEach(function () { + deviceFanSpeedMapper = imock(); + deviceWaterLevelMapper = imock(); + cleanModeMapper = imock(); + weekDayListMapper = imock(); + mapper = new DeviceOrderMapper( + instance(deviceFanSpeedMapper), + instance(deviceWaterLevelMapper), + instance(cleanModeMapper), + instance(weekDayListMapper), + ); + }); + + describe('#toDomain()', function () { + it('should return a DeviceOrder', function () { + when(deviceFanSpeedMapper.toDomain(anything())).thenReturn(new DeviceFanSpeed(DeviceFanSpeedValue.Off)); + when(deviceWaterLevelMapper.toDomain(anything())).thenReturn(new DeviceWaterLevel(DeviceWaterLevelValue.Off)); + when(cleanModeMapper.toDomain(anything())).thenReturn(new CleanMode(CleanModeValue.Auto)); + when(weekDayListMapper.toDomain(anything())).thenReturn([new WeekDay(WeekDayValue.Monday)]); + + const deviceOrder = mapper.toDomain({ + orderId: 1, + enable: true, + repeat: true, + weekDay: 1, + dayTime: 90, + cleanInfo: { + mapHeadId: 2, + planId: 3, + cleanMode: 4, + windPower: 5, + waterLevel: 6, + twiceClean: true, + }, + }); + + expect(deviceOrder).to.be.instanceOf(DeviceOrder); + expect(deviceOrder.id.equals(new ID(1))).to.be.true; + expect(deviceOrder.mapId.equals(new ID(2))).to.be.true; + expect(deviceOrder.planId.equals(new ID(3))).to.be.true; + expect(deviceOrder.isEnabled).to.be.true; + expect(deviceOrder.isRepeatable).to.be.true; + expect(deviceOrder.isDeepClean).to.be.true; + expect(deviceOrder.weekDays).to.deep.contain(new WeekDay(WeekDayValue.Monday)); + expect(deviceOrder.time.equals(new DeviceTime({ hours: 1, minutes: 30 }))).to.be.true; + expect(deviceOrder.cleanMode.equals(new CleanMode(CleanModeValue.Auto))).to.be.true; + expect(deviceOrder.fanSpeed.equals(new DeviceFanSpeed(DeviceFanSpeedValue.Off))).to.be.true; + expect(deviceOrder.waterLevel.equals(new DeviceWaterLevel(DeviceWaterLevelValue.Off))).to.be.true; + + verify(deviceFanSpeedMapper.toDomain(5)).once(); + verify(deviceWaterLevelMapper.toDomain(6)).once(); + verify(cleanModeMapper.toDomain(4)).once(); + verify(weekDayListMapper.toDomain(1)).once(); + }); + + it('should return a DeviceOrder when order list has no water level', function () { + when(deviceFanSpeedMapper.toDomain(anything())).thenReturn(new DeviceFanSpeed(DeviceFanSpeedValue.Off)); + when(cleanModeMapper.toDomain(anything())).thenReturn(new CleanMode(CleanModeValue.Auto)); + when(weekDayListMapper.toDomain(anything())).thenReturn([new WeekDay(WeekDayValue.Monday)]); + + const deviceOrder = mapper.toDomain({ + orderId: 1, + enable: true, + repeat: true, + weekDay: 1, + dayTime: 90, + cleanInfo: { + mapHeadId: 2, + planId: 3, + cleanMode: 4, + windPower: 5, + twiceClean: true, + }, + }); + + expect(deviceOrder).to.be.instanceOf(DeviceOrder); + expect(deviceOrder.id.equals(new ID(1))).to.be.true; + expect(deviceOrder.mapId.equals(new ID(2))).to.be.true; + expect(deviceOrder.planId.equals(new ID(3))).to.be.true; + expect(deviceOrder.isEnabled).to.be.true; + expect(deviceOrder.isRepeatable).to.be.true; + expect(deviceOrder.isDeepClean).to.be.true; + expect(deviceOrder.weekDays).to.deep.contain(new WeekDay(WeekDayValue.Monday)); + expect(deviceOrder.time.equals(new DeviceTime({ hours: 1, minutes: 30 }))).to.be.true; + expect(deviceOrder.cleanMode.equals(new CleanMode(CleanModeValue.Auto))).to.be.true; + expect(deviceOrder.fanSpeed.equals(new DeviceFanSpeed(DeviceFanSpeedValue.Off))).to.be.true; + expect(deviceOrder.waterLevel.equals(new DeviceWaterLevel(DeviceWaterLevelValue.Off))).to.be.true; + + verify(deviceFanSpeedMapper.toDomain(5)).once(); + verify(deviceWaterLevelMapper.toDomain(anything())).never(); + verify(cleanModeMapper.toDomain(4)).once(); + verify(weekDayListMapper.toDomain(1)).once(); + }); + + it("should throw an error when 'cleanInfo' is not defined", function () { + expect(() => + mapper.toDomain({ + orderId: 1, + enable: true, + repeat: true, + weekDay: 1, + dayTime: 90, + }), + ).to.throw(ArgumentNotProvidedException, 'Unable to read clean info from order list'); + }); + }); + + describe('#fromDomain()', function () { + it('should return an order list', function () { + const deviceOrder = new DeviceOrder({ + id: new ID(1), + mapId: new ID(2), + planId: new ID(3), + isEnabled: true, + isRepeatable: true, + isDeepClean: true, + weekDays: [new WeekDay(WeekDayValue.Monday)], + time: new DeviceTime({ hours: 1, minutes: 30 }), + cleanMode: new CleanMode(CleanModeValue.Auto), + fanSpeed: new DeviceFanSpeed(DeviceFanSpeedValue.Off), + waterLevel: new DeviceWaterLevel(DeviceWaterLevelValue.Off), + }); + + when(deviceFanSpeedMapper.fromDomain(anything())).thenReturn(5); + when(deviceWaterLevelMapper.fromDomain(anything())).thenReturn(6); + when(cleanModeMapper.fromDomain(anything())).thenReturn(4); + when(weekDayListMapper.fromDomain(anything())).thenReturn(1); + + const orderList = mapper.fromDomain(deviceOrder); + + expect(orderList.orderId).to.be.equal(1); + expect(orderList.enable).to.be.true; + expect(orderList.repeat).to.be.true; + expect(orderList.weekDay).to.be.equal(1); + expect(orderList.dayTime).to.be.equal(90); + expect(orderList.cleanInfo).to.be.deep.equal({ + mapHeadId: 2, + planId: 3, + cleanMode: 4, + windPower: 5, + waterLevel: 6, + twiceClean: true, + }); + + verify(deviceFanSpeedMapper.fromDomain(deviceOrder.fanSpeed)).once(); + verify(deviceWaterLevelMapper.fromDomain(deviceOrder.waterLevel)).once(); + verify(cleanModeMapper.fromDomain(deviceOrder.cleanMode)).once(); + verify(weekDayListMapper.fromDomain(deviceOrder.weekDays)).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-state.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-state.mapper.test.ts new file mode 100644 index 00000000..4e67af52 --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-state.mapper.test.ts @@ -0,0 +1,76 @@ +import { DeviceState, DeviceStateValue } from '@agnoc/domain'; +import { DomainException, NotImplementedException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceStateMapper } from './device-state.mapper'; + +describe('DeviceStateMapper', function () { + let mapper: DeviceStateMapper; + + beforeEach(function () { + mapper = new DeviceStateMapper(); + }); + + describe('#toDomain()', function () { + it('should return an error device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: true, type: 1, workMode: 0 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Error); + }); + + it('should return a manual control device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: false, type: 0, workMode: 2 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.ManualControl); + }); + + it('should return a returning device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: false, type: 0, workMode: 5 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Returning); + }); + + it('should return a paused device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: false, type: 0, workMode: 4 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Paused); + }); + + it('should return a cleaning device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: false, type: 0, workMode: 1 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Cleaning); + }); + + it('should return a docked device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: true, type: 0, workMode: 0 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Docked); + }); + + it('should return a idle device state', function () { + const deviceState = mapper.toDomain({ chargeStatus: false, type: 0, workMode: 0 }); + + expect(deviceState).to.be.instanceOf(DeviceState); + expect(deviceState.value).to.be.equal(DeviceStateValue.Idle); + }); + + it('should throw an error when device state is unknown', function () { + expect(() => mapper.toDomain({ chargeStatus: false, type: 0, workMode: 100 })).to.throw( + DomainException, + 'Unable to map device state from data: {"chargeStatus":false,"type":0,"workMode":100}', + ); + }); + }); + + describe('#fromDomain()', function () { + it('should throw an error', function () { + expect(() => mapper.fromDomain()).to.throw(NotImplementedException, 'DeviceStateMapper.fromDomain'); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-state.mapper.ts b/packages/adapter-tcp/src/mappers/device-state.mapper.ts index 3a5956f9..e1819f5b 100644 --- a/packages/adapter-tcp/src/mappers/device-state.mapper.ts +++ b/packages/adapter-tcp/src/mappers/device-state.mapper.ts @@ -81,6 +81,6 @@ export class DeviceStateMapper implements Mapper { } fromDomain(): RobotState { - throw new NotImplementedException('DeviceStateMapper.toRobot'); + throw new NotImplementedException(`${this.constructor.name}.fromDomain`); } } diff --git a/packages/adapter-tcp/src/mappers/device-water-level.mapper.test.ts b/packages/adapter-tcp/src/mappers/device-water-level.mapper.test.ts new file mode 100644 index 00000000..1d43344f --- /dev/null +++ b/packages/adapter-tcp/src/mappers/device-water-level.mapper.test.ts @@ -0,0 +1,29 @@ +import { DeviceWaterLevel, DeviceWaterLevelValue } from '@agnoc/domain'; +import { expect } from 'chai'; +import { DeviceWaterLevelMapper } from './device-water-level.mapper'; + +describe('DeviceWaterLevelMapper', function () { + let mapper: DeviceWaterLevelMapper; + + beforeEach(function () { + mapper = new DeviceWaterLevelMapper(); + }); + + describe('#toDomain()', function () { + it('should return a DeviceWaterLevel', function () { + const deviceWaterLevel = mapper.toDomain(10); + + expect(deviceWaterLevel).to.be.instanceOf(DeviceWaterLevel); + expect(deviceWaterLevel.value).to.be.equal(DeviceWaterLevelValue.Off); + }); + }); + + describe('#fromDomain()', function () { + it('should return a number', function () { + const deviceWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Off); + const deviceWaterLevelValue = mapper.fromDomain(deviceWaterLevel); + + expect(deviceWaterLevelValue).to.be.equal(10); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/voice-setting.mapper.test.ts b/packages/adapter-tcp/src/mappers/voice-setting.mapper.test.ts new file mode 100644 index 00000000..d0194d8f --- /dev/null +++ b/packages/adapter-tcp/src/mappers/voice-setting.mapper.test.ts @@ -0,0 +1,39 @@ +import { VoiceSetting } from '@agnoc/domain'; +import { expect } from 'chai'; +import { VoiceSettingMapper } from './voice-setting.mapper'; + +describe('VoiceSettingMapper', function () { + let mapper: VoiceSettingMapper; + + beforeEach(function () { + mapper = new VoiceSettingMapper(); + }); + + describe('#toDomain()', function () { + it('should return a VoiceSetting', function () { + const voiceSetting = mapper.toDomain({ isEnabled: true, volume: 6 }); + + expect(voiceSetting).to.be.instanceOf(VoiceSetting); + expect(voiceSetting.isEnabled).to.be.equal(true); + expect(voiceSetting.volume).to.be.equal(50); + }); + + it('should return a VoiceSetting with defaults', function () { + const voiceSetting = mapper.toDomain({ isEnabled: undefined, volume: undefined }); + + expect(voiceSetting).to.be.instanceOf(VoiceSetting); + expect(voiceSetting.isEnabled).to.be.equal(false); + expect(voiceSetting.volume).to.be.equal(0); + }); + }); + + describe('#fromDomain()', function () { + it('should return a number', function () { + const voiceSetting = new VoiceSetting({ isEnabled: true, volume: 50 }); + const voiceSettingValue = mapper.fromDomain(voiceSetting); + + expect(voiceSettingValue.isEnabled).to.be.equal(true); + expect(voiceSettingValue.volume).to.be.equal(6); + }); + }); +}); diff --git a/packages/adapter-tcp/src/mappers/device-voice.mapper.ts b/packages/adapter-tcp/src/mappers/voice-setting.mapper.ts similarity index 91% rename from packages/adapter-tcp/src/mappers/device-voice.mapper.ts rename to packages/adapter-tcp/src/mappers/voice-setting.mapper.ts index 7b114115..3a17ae7f 100644 --- a/packages/adapter-tcp/src/mappers/device-voice.mapper.ts +++ b/packages/adapter-tcp/src/mappers/voice-setting.mapper.ts @@ -19,7 +19,7 @@ export interface RobotVoice { volume: number | null | undefined; } -export class DeviceVoiceMapper implements Mapper { +export class VoiceSettingMapper implements Mapper { toDomain({ isEnabled, volume }: RobotVoice): VoiceSetting { return new VoiceSetting({ isEnabled: isEnabled ?? false, diff --git a/packages/adapter-tcp/src/mappers/week-day-list.mapper.test.ts b/packages/adapter-tcp/src/mappers/week-day-list.mapper.test.ts index 6519d093..a6b3e714 100644 --- a/packages/adapter-tcp/src/mappers/week-day-list.mapper.test.ts +++ b/packages/adapter-tcp/src/mappers/week-day-list.mapper.test.ts @@ -3,10 +3,16 @@ import { expect } from 'chai'; import { WeekDayDomainToRobotMap, WeekDayListMapper } from './week-day-list.mapper'; describe('WeekDayListMapper', function () { + let mapper: WeekDayListMapper; + + beforeEach(function () { + mapper = new WeekDayListMapper(); + }); + describe('#toDomain()', function () { it('should return an array of WeekDay', function () { const weekDay = WeekDayDomainToRobotMap.Monday | WeekDayDomainToRobotMap.Wednesday; - const weekDayList = new WeekDayListMapper().toDomain(weekDay); + const weekDayList = mapper.toDomain(weekDay); expect(weekDayList.length).to.be.equal(2); expect(weekDayList[0]).to.be.instanceOf(WeekDay); @@ -19,7 +25,7 @@ describe('WeekDayListMapper', function () { describe('#fromDomain()', function () { it('should return a number', function () { const weekDayList = [new WeekDay(WeekDayValue.Monday), new WeekDay(WeekDayValue.Wednesday)]; - const weekDay = new WeekDayListMapper().fromDomain(weekDayList); + const weekDay = mapper.fromDomain(weekDayList); expect(weekDay).to.be.equal(WeekDayDomainToRobotMap.Monday | WeekDayDomainToRobotMap.Wednesday); }); diff --git a/packages/adapter-tcp/src/objects/packet.message.test.ts b/packages/adapter-tcp/src/objects/packet.message.test.ts new file mode 100644 index 00000000..ef4ab5b1 --- /dev/null +++ b/packages/adapter-tcp/src/objects/packet.message.test.ts @@ -0,0 +1,108 @@ +import { Device } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { DomainException } from '@agnoc/toolkit'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps, givenSomePayloadProps } from '@agnoc/transport-tcp/test-support'; +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { PacketMessage } from './packet.message'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; + +describe('PacketMessage', function () { + let packetConnection: PacketConnection; + let packet: Packet; + let packetMessage: PacketMessage; + + beforeEach(function () { + packetConnection = imock(); + packet = imock(); + packetMessage = new PacketMessage(instance(packetConnection), instance(packet)); + }); + + it('should be created', function () { + const device = new Device(givenSomeDeviceProps()); + + when(packetConnection.device).thenReturn(device); + + expect(packetMessage).to.exist; + expect(packetMessage.device).to.be.equal(device); + }); + + describe('#respond()', function () { + it('should respond to the connection', async function () { + const data = { result: 0, body: { deviceTime: 1 } }; + + when(packetConnection.respond(anything(), anything(), anything())).thenResolve(undefined); + + await packetMessage.respond('DEVICE_GETTIME_RSP', data); + + verify(packetConnection.respond('DEVICE_GETTIME_RSP', data, instance(packet))).once(); + }); + }); + + describe('#respondAndWait()', function () { + it('should respond to the connection and wait for the response', async function () { + const data = { result: 0, body: { deviceTime: 1 } }; + const anotherPacketMessage = new PacketMessage(instance(packetConnection), instance(packet)); + + when(packetConnection.respondAndWait(anything(), anything(), anything())).thenResolve(anotherPacketMessage); + + const ret = await packetMessage.respondAndWait('DEVICE_GETTIME_RSP', data); + + expect(ret).to.be.equal(anotherPacketMessage); + + verify(packetConnection.respondAndWait('DEVICE_GETTIME_RSP', data, instance(packet))).once(); + }); + }); + + describe('#hasPayloadName()', function () { + it('should return true when the payload name is the same', function () { + const opcode = OPCode.fromName('DEVICE_GETTIME_RSP'); + const payload = new Payload({ ...givenSomePayloadProps(), opcode }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + expect(packetMessage.hasPayloadName('DEVICE_GETTIME_RSP')).to.be.true; + }); + + it('should return false when the payload name is not the same', function () { + const opcode = OPCode.fromName('DEVICE_GETTIME_RSP'); + const payload = new Payload({ ...givenSomePayloadProps(), opcode }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + expect(packetMessage.hasPayloadName('DEVICE_GETTIME_REQ')).to.be.false; + }); + }); + + describe('#assertPayloadName()', function () { + it('should throw an error when the payload name is not the same', function () { + const opcode = OPCode.fromName('DEVICE_GETTIME_RSP'); + const payload = new Payload({ ...givenSomePayloadProps(), opcode }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + expect(() => packetMessage.assertPayloadName('DEVICE_GETTIME_REQ')).to.throw( + DomainException, + `Unexpected packet with payload name 'DEVICE_GETTIME_RSP', expecting 'DEVICE_GETTIME_REQ'`, + ); + }); + + it('should not throw an error when the payload name is the same', function () { + const opcode = OPCode.fromName('DEVICE_GETTIME_RSP'); + const payload = new Payload({ ...givenSomePayloadProps(), opcode }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + expect(() => packetMessage.assertPayloadName('DEVICE_GETTIME_RSP')).to.not.throw(DomainException); + }); + }); + + describe('#assertDevice()', function () { + it('should invoke connection device assertion', function () { + packetMessage.assertDevice(); + + verify(packetConnection.assertDevice()).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/objects/packet.message.ts b/packages/adapter-tcp/src/objects/packet.message.ts new file mode 100644 index 00000000..8b83fd9e --- /dev/null +++ b/packages/adapter-tcp/src/objects/packet.message.ts @@ -0,0 +1,38 @@ +import { DomainException } from '@agnoc/toolkit'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { Device } from '@agnoc/domain'; +import type { Packet, PayloadDataFrom, PayloadDataName } from '@agnoc/transport-tcp'; + +export class PacketMessage { + constructor(readonly connection: PacketConnection, readonly packet: Packet) {} + + get device(): Device | undefined { + return this.connection.device; + } + + respond(name: Name, object: PayloadDataFrom): Promise { + return this.connection.respond(name, object, this.packet); + } + + respondAndWait(name: Name, object: PayloadDataFrom): Promise { + return this.connection.respondAndWait(name, object, this.packet); + } + + // TODO: move this to packet. + hasPayloadName(name: Name): this is PacketMessage { + return this.packet.payload.opcode.value === (name as string); + } + + // TODO: move this to packet. + assertPayloadName(name: Name): asserts this is PacketMessage { + if (!this.hasPayloadName(name)) { + throw new DomainException( + `Unexpected packet with payload name '${this.packet.payload.opcode.value}', expecting '${name}'`, + ); + } + } + + assertDevice(): asserts this is PacketMessage & { device: Device } { + this.connection.assertDevice(); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.test.ts new file mode 100644 index 00000000..0273a168 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.test.ts @@ -0,0 +1,26 @@ +import { deepEqual, imock, instance, verify } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ClientHeartbeatEventHandler } from './client-heartbeat.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('ClientHeartbeatEventHandler', function () { + let eventHandler: ClientHeartbeatEventHandler; + let packetMessage: PacketMessage<'CLIENT_HEARTBEAT_REQ'>; + + beforeEach(function () { + eventHandler = new ClientHeartbeatEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('CLIENT_HEARTBEAT_REQ'); + }); + + describe('#handle()', function () { + it('should respond to the message', async function () { + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('CLIENT_HEARTBEAT_RSP', deepEqual({}))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.ts new file mode 100644 index 00000000..f5c03a02 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/client-heartbeat.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class ClientHeartbeatEventHandler implements PacketEventHandler { + readonly forName = 'CLIENT_HEARTBEAT_REQ'; + + async handle(message: PacketMessage<'CLIENT_HEARTBEAT_REQ'>): Promise { + await message.respond('CLIENT_HEARTBEAT_RSP', {}); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.test.ts new file mode 100644 index 00000000..2993fbef --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.test.ts @@ -0,0 +1,53 @@ +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ClientLoginEventHandler } from './client-login.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device } from '@agnoc/domain'; + +describe('ClientLoginEventHandler', function () { + let eventHandler: ClientLoginEventHandler; + let packetMessage: PacketMessage<'CLIENT_ONLINE_REQ'>; + let device: Device; + + beforeEach(function () { + eventHandler = new ClientLoginEventHandler(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('CLIENT_ONLINE_REQ'); + }); + + describe('#handle()', function () { + it('should respond when it has device', async function () { + when(packetMessage.device).thenReturn(device); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('CLIENT_ONLINE_RSP', deepEqual({ result: 0 }))).once(); + }); + + it('should respond when it does not has device', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('CLIENT_ONLINE_REQ'), + data: { deviceSerialNumber: '1234567890', unk1: true, unk2: 1 }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.device).thenReturn(undefined); + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + verify( + packetMessage.respond( + 'CLIENT_ONLINE_RSP', + deepEqual({ result: 12002, reason: 'Device not registered(devsn: 1234567890)' }), + ), + ).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.ts new file mode 100644 index 00000000..d559582a --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/client-login.event-handler.ts @@ -0,0 +1,19 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class ClientLoginEventHandler implements PacketEventHandler { + readonly forName = 'CLIENT_ONLINE_REQ'; + + async handle(message: PacketMessage<'CLIENT_ONLINE_REQ'>): Promise { + if (!message.device) { + const data = { + result: 12002, + reason: `Device not registered(devsn: ${message.packet.payload.data.deviceSerialNumber})`, + }; + + return message.respond('CLIENT_ONLINE_RSP', data); + } + + await message.respond('CLIENT_ONLINE_RSP', { result: 0 }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.test.ts new file mode 100644 index 00000000..19c5a1a1 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.test.ts @@ -0,0 +1,52 @@ +import { DeviceBattery } from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceBatteryUpdateEventHandler } from './device-battery-update.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceBatteryUpdateEventHandler', function () { + let deviceBatteryMapper: DeviceBatteryMapper; + let deviceRepository: DeviceRepository; + let eventHandler: DeviceBatteryUpdateEventHandler; + let packetMessage: PacketMessage<'PUSH_DEVICE_BATTERY_INFO_REQ'>; + let device: Device; + + beforeEach(function () { + deviceBatteryMapper = imock(); + deviceRepository = imock(); + eventHandler = new DeviceBatteryUpdateEventHandler(instance(deviceBatteryMapper), instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('PUSH_DEVICE_BATTERY_INFO_REQ'); + }); + + describe('#handle()', function () { + it('should update the device battery', async function () { + const deviceBattery = new DeviceBattery(5); + const payload = new Payload({ + opcode: OPCode.fromName('PUSH_DEVICE_BATTERY_INFO_REQ'), + data: { battery: { level: 1 } }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceBatteryMapper.toDomain(anything())).thenReturn(deviceBattery); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceBatteryMapper.toDomain(1)).once(); + verify(device.updateBattery(deviceBattery)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + verify(packetMessage.respond('PUSH_DEVICE_BATTERY_INFO_RSP', deepEqual({ result: 0 }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.ts new file mode 100644 index 00000000..26becae1 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-battery-update.event-handler.ts @@ -0,0 +1,25 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceBatteryUpdateEventHandler implements PacketEventHandler { + readonly forName = 'PUSH_DEVICE_BATTERY_INFO_REQ'; + + constructor( + private readonly deviceBatteryMapper: DeviceBatteryMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(message: PacketMessage<'PUSH_DEVICE_BATTERY_INFO_REQ'>): Promise { + message.assertDevice(); + + const data = message.packet.payload.data; + + message.device.updateBattery(this.deviceBatteryMapper.toDomain(data.battery.level)); + + await this.deviceRepository.saveOne(message.device); + + await message.respond('PUSH_DEVICE_BATTERY_INFO_RSP', { result: 0 }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.test.ts new file mode 100644 index 00000000..4aedd464 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.test.ts @@ -0,0 +1,36 @@ +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceCleanMapDataReportEventHandler } from './device-clean-map-data-report.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceCleanMapDataReportEventHandler', function () { + let eventHandler: DeviceCleanMapDataReportEventHandler; + let packetMessage: PacketMessage<'DEVICE_CLEANMAP_BINDATA_REPORT_REQ'>; + + beforeEach(function () { + eventHandler = new DeviceCleanMapDataReportEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_CLEANMAP_BINDATA_REPORT_REQ'); + }); + + describe('#handle()', function () { + it('should update the device version', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_CLEANMAP_BINDATA_REPORT_REQ'), + data: { cleanId: 1 }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('DEVICE_CLEANMAP_BINDATA_REPORT_RSP', deepEqual({ result: 0, cleanId: 1 }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.ts new file mode 100644 index 00000000..49f09b32 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-data-report.event-handler.ts @@ -0,0 +1,14 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceCleanMapDataReportEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_CLEANMAP_BINDATA_REPORT_REQ'; + + async handle(message: PacketMessage<'DEVICE_CLEANMAP_BINDATA_REPORT_REQ'>): Promise { + const data = message.packet.payload.data; + + // TODO: save device clean map data + + await message.respond('DEVICE_CLEANMAP_BINDATA_REPORT_RSP', { result: 0, cleanId: data.cleanId }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.test.ts new file mode 100644 index 00000000..f690256f --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.test.ts @@ -0,0 +1,36 @@ +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceCleanMapReportEventHandler } from './device-clean-map-report.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceCleanMapReportEventHandler', function () { + let eventHandler: DeviceCleanMapReportEventHandler; + let packetMessage: PacketMessage<'DEVICE_EVENT_REPORT_CLEANMAP'>; + + beforeEach(function () { + eventHandler = new DeviceCleanMapReportEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_EVENT_REPORT_CLEANMAP'); + }); + + describe('#handle()', function () { + it('should update the device version', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_EVENT_REPORT_CLEANMAP'), + data: { cleanId: 1 }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('DEVICE_EVENT_REPORT_RSP', deepEqual({ result: 0, body: { cleanId: 1 } }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.ts new file mode 100644 index 00000000..e184a9ba --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-map-report.event-handler.ts @@ -0,0 +1,14 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceCleanMapReportEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_EVENT_REPORT_CLEANMAP'; + + async handle(message: PacketMessage<'DEVICE_EVENT_REPORT_CLEANMAP'>): Promise { + const data = message.packet.payload.data; + + // TODO: save device clean map data + + await message.respond('DEVICE_EVENT_REPORT_RSP', { result: 0, body: { cleanId: data.cleanId } }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.test.ts new file mode 100644 index 00000000..1e2a5244 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.test.ts @@ -0,0 +1,48 @@ +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceCleanTaskReportEventHandler } from './device-clean-task-report.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceCleanTaskReportEventHandler', function () { + let eventHandler: DeviceCleanTaskReportEventHandler; + let packetMessage: PacketMessage<'DEVICE_EVENT_REPORT_CLEANTASK'>; + + beforeEach(function () { + eventHandler = new DeviceCleanTaskReportEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_EVENT_REPORT_CLEANTASK'); + }); + + describe('#handle()', function () { + it('should update the device version', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_EVENT_REPORT_CLEANTASK'), + data: { + cleanId: 1, + startTime: 0, + endTime: 180, + cleanTime: 0, + cleanSize: 0, + unk6: 0, + unk7: 0, + unk8: { unk1Unk1: 0, unk1Unk2: 0, unk1Unk6: 0 }, + mapHeadId: 2, + mapName: 'map', + planName: 'plan', + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('UNK_11A4', deepEqual({ unk1: 0 }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.ts new file mode 100644 index 00000000..6f32e0fc --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-clean-task-report.event-handler.ts @@ -0,0 +1,12 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceCleanTaskReportEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_EVENT_REPORT_CLEANTASK'; + + async handle(message: PacketMessage<'DEVICE_EVENT_REPORT_CLEANTASK'>): Promise { + // TODO: save device clean task data + + await message.respond('UNK_11A4', { unk1: 0 }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.test.ts new file mode 100644 index 00000000..a2597cd1 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.test.ts @@ -0,0 +1,24 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceGetAllGlobalMapEventHandler } from './device-get-all-global-map.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceGetAllGlobalMapEventHandler', function () { + let eventHandler: DeviceGetAllGlobalMapEventHandler; + let packetMessage: PacketMessage<'DEVICE_GET_ALL_GLOBAL_MAP_INFO_RSP'>; + + beforeEach(function () { + eventHandler = new DeviceGetAllGlobalMapEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_GET_ALL_GLOBAL_MAP_INFO_RSP'); + }); + + describe('#handle()', function () { + it('should do nothing', async function () { + await eventHandler.handle(instance(packetMessage)); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.ts new file mode 100644 index 00000000..067866e8 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-get-all-global-map.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceGetAllGlobalMapEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_GET_ALL_GLOBAL_MAP_INFO_RSP'; + + async handle(_: PacketMessage<'DEVICE_GET_ALL_GLOBAL_MAP_INFO_RSP'>): Promise { + // TODO: investigate the meaning of this packet. + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.test.ts new file mode 100644 index 00000000..3a726c99 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.test.ts @@ -0,0 +1,24 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceLocatedEventHandler } from './device-located.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceLocatedEventHandler', function () { + let eventHandler: DeviceLocatedEventHandler; + let packetMessage: PacketMessage<'DEVICE_SEEK_LOCATION_RSP'>; + + beforeEach(function () { + eventHandler = new DeviceLocatedEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_SEEK_LOCATION_RSP'); + }); + + describe('#handle()', function () { + it('should do nothing', async function () { + await eventHandler.handle(instance(packetMessage)); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.ts new file mode 100644 index 00000000..a3775eb9 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-located.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceLocatedEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_SEEK_LOCATION_RSP'; + + async handle(_: PacketMessage<'DEVICE_SEEK_LOCATION_RSP'>): Promise { + // TODO: Should do something here? + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.test.ts new file mode 100644 index 00000000..c811edda --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.test.ts @@ -0,0 +1,47 @@ +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceLockedEventHandler } from './device-locked.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceLockedEventHandler', function () { + let deviceRepository: DeviceRepository; + let eventHandler: DeviceLockedEventHandler; + let packetMessage: PacketMessage<'DEVICE_CONTROL_LOCK_RSP'>; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + eventHandler = new DeviceLockedEventHandler(instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_CONTROL_LOCK_RSP'); + }); + + describe('#handle()', function () { + it('should lock the device when is not locked', async function () { + when(packetMessage.device).thenReturn(instance(device)); + when(device.isLocked).thenReturn(false); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(device.setAsLocked()).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when device is already locked', async function () { + when(packetMessage.device).thenReturn(instance(device)); + when(device.isLocked).thenReturn(true); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(device.setAsLocked()).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.ts new file mode 100644 index 00000000..ba505c4e --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-locked.event-handler.ts @@ -0,0 +1,19 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceLockedEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_CONTROL_LOCK_RSP'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(message: PacketMessage<'DEVICE_CONTROL_LOCK_RSP'>): Promise { + message.assertDevice(); + + if (!message.device.isLocked) { + message.device.setAsLocked(); + + await this.deviceRepository.saveOne(message.device); + } + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.test.ts new file mode 100644 index 00000000..8a08b6d0 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.test.ts @@ -0,0 +1,78 @@ +import { MapPosition } from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceMapChargerPositionUpdateEventHandler } from './device-map-charger-position-update.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository, DeviceMap } from '@agnoc/domain'; + +describe('DeviceMapChargerPositionUpdateEventHandler', function () { + let deviceRepository: DeviceRepository; + let eventHandler: DeviceMapChargerPositionUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'>; + let device: Device; + let deviceMap: DeviceMap; + + beforeEach(function () { + deviceRepository = imock(); + eventHandler = new DeviceMapChargerPositionUpdateEventHandler(instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + deviceMap = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'); + }); + + describe('#handle()', function () { + it('should update the device network', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'), + data: { + poseId: 0, + poseX: 1, + poseY: 2, + posePhi: 3, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceMap.updateCharger(deepEqual(new MapPosition({ x: 1, y: 2, phi: 3 })))).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do nothing when device has no map', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'), + data: { + poseId: 0, + poseX: 1, + poseY: 2, + posePhi: 3, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(undefined); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceMap.updateCharger(anything())).never(); + verify(device.updateMap(anything())).never(); + verify(deviceRepository.saveOne(anything())).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.ts new file mode 100644 index 00000000..4ecf0adf --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-charger-position-update.event-handler.ts @@ -0,0 +1,30 @@ +import { MapPosition } from '@agnoc/domain'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceMapChargerPositionUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(message: PacketMessage<'DEVICE_MAPID_PUSH_CHARGE_POSITION_INFO'>): Promise { + message.assertDevice(); + + if (message.device.map) { + const data = message.packet.payload.data; + + message.device.map.updateCharger( + new MapPosition({ + x: data.poseX, + y: data.poseY, + phi: data.posePhi, + }), + ); + + message.device.updateMap(message.device.map); + + await this.deviceRepository.saveOne(message.device); + } + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.test.ts new file mode 100644 index 00000000..0e649811 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.test.ts @@ -0,0 +1,469 @@ +import { + CleanSize, + DeviceBattery, + DeviceCleanWork, + DeviceError, + DeviceErrorValue, + DeviceFanSpeed, + DeviceFanSpeedValue, + DeviceMode, + DeviceModeValue, + DeviceState, + DeviceStateValue, + DeviceTime, + MapCoordinate, + MapPixel, + MapPosition, + DeviceMap, + Zone, + Room, +} from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceMapUpdateEventHandler } from './device-map-update.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { DeviceErrorMapper } from '../mappers/device-error.mapper'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { DeviceModeMapper } from '../mappers/device-mode.mapper'; +import type { DeviceStateMapper } from '../mappers/device-state.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceMapUpdateEventHandler', function () { + let deviceBatteryMapper: DeviceBatteryMapper; + let deviceModeMapper: DeviceModeMapper; + let deviceStateMapper: DeviceStateMapper; + let deviceErrorMapper: DeviceErrorMapper; + let deviceFanSpeedMapper: DeviceFanSpeedMapper; + let deviceRepository: DeviceRepository; + let eventHandler: DeviceMapUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_MAPID_GET_GLOBAL_INFO_RSP'>; + let device: Device; + let deviceMap: DeviceMap; + + beforeEach(function () { + deviceBatteryMapper = imock(); + deviceModeMapper = imock(); + deviceStateMapper = imock(); + deviceErrorMapper = imock(); + deviceFanSpeedMapper = imock(); + deviceRepository = imock(); + eventHandler = new DeviceMapUpdateEventHandler( + instance(deviceBatteryMapper), + instance(deviceModeMapper), + instance(deviceStateMapper), + instance(deviceErrorMapper), + instance(deviceFanSpeedMapper), + instance(deviceRepository), + ); + packetMessage = imock(); + device = imock(); + deviceMap = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'); + }); + + describe('#handle()', function () { + it('should do nothing when there is no data', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { mask: 0 }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(undefined); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceBatteryMapper.toDomain(anything())).never(); + verify(deviceModeMapper.toDomain(anything())).never(); + verify(deviceStateMapper.toDomain(anything())).never(); + verify(deviceErrorMapper.toDomain(anything())).never(); + verify(deviceFanSpeedMapper.toDomain(anything())).never(); + verify(device.updateMap(anything())).never(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update the status info', async function () { + const deviceBattery = new DeviceBattery(5); + const deviceState = new DeviceState(DeviceStateValue.Docked); + const deviceMode = new DeviceMode(DeviceModeValue.None); + const deviceError = new DeviceError(DeviceErrorValue.None); + const deviceFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + statusInfo: { + mapHeadId: 1, + hasHistoryMap: true, + workingMode: 0, + batteryPercent: 100, + chargeState: false, + faultType: 3, + faultCode: 2103, + cleanPreference: 1, + repeatClean: false, + cleanTime: 0, + cleanSize: 0, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceBatteryMapper.toDomain(anything())).thenReturn(deviceBattery); + when(deviceStateMapper.toDomain(anything())).thenReturn(deviceState); + when(deviceModeMapper.toDomain(anything())).thenReturn(deviceMode); + when(deviceErrorMapper.toDomain(anything())).thenReturn(deviceError); + when(deviceFanSpeedMapper.toDomain(anything())).thenReturn(deviceFanSpeed); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(undefined); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceStateMapper.toDomain(deepEqual({ type: 3, workMode: 0, chargeStatus: false }))).once(); + verify(deviceModeMapper.toDomain(0)).once(); + verify(deviceErrorMapper.toDomain(2103)).once(); + verify(deviceBatteryMapper.toDomain(100)).once(); + verify(deviceFanSpeedMapper.toDomain(1)).once(); + verify( + device.updateCurrentCleanWork( + deepEqual(new DeviceCleanWork({ size: new CleanSize(0), time: DeviceTime.fromMinutes(0) })), + ), + ).once(); + verify(device.updateState(deviceState)).once(); + verify(device.updateMode(deviceMode)).once(); + verify(device.updateError(deviceError)).once(); + verify(device.updateBattery(deviceBattery)).once(); + verify(device.updateFanSpeed(deviceFanSpeed)).once(); + verify(device.updateMap(anything())).never(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do create a map', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + mapHeadInfo: { + mapHeadId: 1, + mapValid: 1, + mapType: 1, + sizeX: 800, + sizeY: 800, + minX: -20, + minY: -20, + maxX: 20, + maxY: 20, + resolution: 0.05, + }, + mapGrid: Buffer.alloc(0), + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(undefined); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify( + device.updateMap( + deepEqual( + new DeviceMap({ + id: new ID(1), + size: new MapPixel({ x: 800, y: 800 }), + min: new MapCoordinate({ x: -20, y: -20 }), + max: new MapCoordinate({ x: 20, y: 20 }), + resolution: 0.05, + grid: Buffer.alloc(0), + rooms: [], + restrictedZones: [], + robotPath: [], + }), + ), + ), + ).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update a map', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + mapHeadInfo: { + mapHeadId: 1, + mapValid: 1, + mapType: 1, + sizeX: 800, + sizeY: 800, + minX: -20, + minY: -20, + maxX: 20, + maxY: 20, + resolution: 0.05, + }, + mapGrid: Buffer.alloc(0), + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + when(deviceMap.clone(anything())).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify( + deviceMap.clone( + deepEqual({ + id: new ID(1), + size: new MapPixel({ x: 800, y: 800 }), + min: new MapCoordinate({ x: -20, y: -20 }), + max: new MapCoordinate({ x: 20, y: 20 }), + resolution: 0.05, + grid: Buffer.alloc(0), + rooms: [], + restrictedZones: [], + robotPath: [], + }), + ), + ).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map robot path', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + historyHeadInfo: { + mapHeadId: 1, + poseId: 1, + pointList: [ + { flag: 0, x: 0, y: 0 }, + { flag: 1, x: 1, y: 1 }, + ], + pointNumber: 0, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + when(deviceMap.robotPath).thenReturn([]); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify( + deviceMap.updateRobotPath(deepEqual([new MapCoordinate({ x: 0, y: 0 }), new MapCoordinate({ x: 1, y: 1 })])), + ).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map robot position', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + robotPoseInfo: { + mapHeadId: 1, + poseId: 0, + update: 1, + poseX: 0, + poseY: 1, + posePhi: 2, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceMap.updateRobot(deepEqual(new MapPosition({ x: 0, y: 1, phi: 2 })))).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map charger position', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + robotChargeInfo: { + mapHeadId: 1, + poseX: 0, + poseY: 1, + posePhi: 2, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceMap.updateCharger(deepEqual(new MapPosition({ x: 0, y: 1, phi: 2 })))).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map spot position', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + spotInfo: { + mapHeadId: 1, + ctrlValue: 0, + poseX: 0, + poseY: 1, + posePhi: 2, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceMap.updateCurrentSpot(deepEqual(new MapPosition({ x: 0, y: 1, phi: 2 })))).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map restricted zones', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + wallListInfo: { + mapHeadId: 1, + cleanPlanId: 2, + cleanAreaList: [ + { + cleanAreaId: 3, + cleanPlanId: 2, + coordinateList: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ], + }, + ], + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify( + deviceMap.updateRestrictedZones( + deepEqual([ + new Zone({ + id: new ID(3), + coordinates: [new MapCoordinate({ x: 0, y: 0 }), new MapCoordinate({ x: 1, y: 1 })], + }), + ]), + ), + ).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should do update map rooms', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'), + data: { + mask: 0, + currentPlanId: 2, + cleanPlanList: [ + { + planId: 2, + planName: 'plan1', + mapHeadId: 1, + currentPlanId: 1, + areaInfoList: [], + cleanRoomInfoList: [{ roomId: 3, enable: 1 }], + }, + ], + cleanRoomList: [ + { roomId: 3, roomName: 'room1', roomState: 0, roomX: 0, roomY: 0 }, + { roomId: 4, roomName: 'room2', roomState: 0, roomX: 0, roomY: 0 }, + ], + roomSegmentList: [ + { + roomId: 3, + roomPixelList: [ + { mask: 0, x: 1, y: 1 }, + { mask: 1, x: 2, y: 2 }, + ], + }, + ], + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + when(device.map).thenReturn(instance(deviceMap)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify( + deviceMap.updateRooms( + deepEqual([ + new Room({ + id: new ID(3), + name: 'room1', + isEnabled: true, + center: new MapCoordinate({ x: 0, y: 0 }), + pixels: [new MapPixel({ x: 1, y: 1 }), new MapPixel({ x: 2, y: 2 })], + }), + ]), + ), + ).once(); + verify(device.updateMap(instance(deviceMap))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.ts new file mode 100644 index 00000000..f35a805b --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-update.event-handler.ts @@ -0,0 +1,199 @@ +import { + DeviceCleanWork, + CleanSize, + DeviceTime, + DeviceMap, + MapCoordinate, + MapPixel, + MapPosition, + Room, + Zone, +} from '@agnoc/domain'; +import { ID, isPresent } from '@agnoc/toolkit'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { DeviceErrorMapper } from '../mappers/device-error.mapper'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { DeviceModeMapper } from '../mappers/device-mode.mapper'; +import type { DeviceStateMapper } from '../mappers/device-state.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceMapUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_MAPID_GET_GLOBAL_INFO_RSP'; + + constructor( + private readonly deviceBatteryMapper: DeviceBatteryMapper, + private readonly deviceModeMapper: DeviceModeMapper, + private readonly deviceStateMapper: DeviceStateMapper, + private readonly deviceErrorMapper: DeviceErrorMapper, + private readonly deviceFanSpeedMapper: DeviceFanSpeedMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(message: PacketMessage<'DEVICE_MAPID_GET_GLOBAL_INFO_RSP'>): Promise { + message.assertDevice(); + + const { + statusInfo, + mapHeadInfo, + mapGrid, + historyHeadInfo, + robotPoseInfo, + robotChargeInfo, + cleanRoomList, + roomSegmentList, + wallListInfo, + spotInfo, + cleanPlanList, + currentPlanId, + } = message.packet.payload.data; + + if (statusInfo) { + const { + batteryPercent: battery, + faultType: type, + workingMode: workMode, + chargeState: chargeStatus, + cleanPreference, + faultCode, + } = statusInfo; + + message.device.updateCurrentCleanWork( + new DeviceCleanWork({ + size: new CleanSize(statusInfo.cleanSize), + time: DeviceTime.fromMinutes(statusInfo.cleanTime), + }), + ); + message.device.updateBattery(this.deviceBatteryMapper.toDomain(battery)); + message.device.updateMode(this.deviceModeMapper.toDomain(workMode)); + message.device.updateState(this.deviceStateMapper.toDomain({ type, workMode, chargeStatus })); + message.device.updateError(this.deviceErrorMapper.toDomain(faultCode)); + message.device.updateFanSpeed(this.deviceFanSpeedMapper.toDomain(cleanPreference)); + } + + let map = message.device.map; + + if (mapHeadInfo && mapGrid) { + const props = { + id: new ID(mapHeadInfo.mapHeadId), + size: new MapPixel({ + x: mapHeadInfo.sizeX, + y: mapHeadInfo.sizeY, + }), + min: new MapCoordinate({ + x: mapHeadInfo.minX, + y: mapHeadInfo.minY, + }), + max: new MapCoordinate({ + x: mapHeadInfo.maxX, + y: mapHeadInfo.maxY, + }), + resolution: mapHeadInfo.resolution, + grid: mapGrid, + rooms: [], + restrictedZones: [], + robotPath: [], + }; + + map = map ? map.clone(props) : new DeviceMap(props); + } + + if (map) { + if (historyHeadInfo) { + const currentIndex = map.robotPath.length; + + map.updateRobotPath( + map.robotPath.concat( + historyHeadInfo.pointList.slice(currentIndex).map(({ x, y }) => new MapCoordinate({ x, y })), + ), + ); + } + + if (robotPoseInfo) { + map.updateRobot( + new MapPosition({ + x: robotPoseInfo.poseX, + y: robotPoseInfo.poseY, + phi: robotPoseInfo.posePhi, + }), + ); + } + + if (robotChargeInfo) { + map.updateCharger( + new MapPosition({ + x: robotChargeInfo.poseX, + y: robotChargeInfo.poseY, + phi: robotChargeInfo.posePhi, + }), + ); + } + + if (spotInfo) { + map.updateCurrentSpot( + new MapPosition({ + x: spotInfo.poseX, + y: spotInfo.poseY, + phi: spotInfo.posePhi, + }), + ); + } + + if (wallListInfo) { + map.updateRestrictedZones( + wallListInfo.cleanAreaList.map((cleanArea) => { + return new Zone({ + id: new ID(cleanArea.cleanAreaId), + coordinates: cleanArea.coordinateList.map(({ x, y }) => { + return new MapCoordinate({ + x, + y, + }); + }), + }); + }), + ); + } + + if (cleanRoomList && roomSegmentList && cleanPlanList) { + const currentPlan = cleanPlanList.find((plan) => plan.planId === currentPlanId); + + map.updateRooms( + cleanRoomList + .map((cleanRoom) => { + const segment = roomSegmentList.find((roomSegment) => roomSegment.roomId === cleanRoom.roomId); + const roomInfo = currentPlan?.cleanRoomInfoList.find((r) => r.roomId === cleanRoom.roomId); + + if (!segment) { + return undefined; + } + + return new Room({ + id: new ID(cleanRoom.roomId), + name: cleanRoom.roomName, + isEnabled: Boolean(roomInfo?.enable), + center: new MapCoordinate({ + x: cleanRoom.roomX, + y: cleanRoom.roomY, + }), + pixels: segment?.roomPixelList.map((pixel) => { + return new MapPixel({ + x: pixel.x, + y: pixel.y, + }); + }), + }); + }) + .filter(isPresent), + ); + } + } + + if (map) { + message.device.updateMap(map); + } + + await this.deviceRepository.saveOne(message.device); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.test.ts new file mode 100644 index 00000000..7c1670d8 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.test.ts @@ -0,0 +1,168 @@ +import { + CleanSize, + DeviceBattery, + DeviceCleanWork, + DeviceError, + DeviceErrorValue, + DeviceFanSpeed, + DeviceFanSpeedValue, + DeviceMode, + DeviceModeValue, + DeviceState, + DeviceStateValue, + DeviceTime, + DeviceWaterLevel, + DeviceWaterLevelValue, +} from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceMapWorkStatusUpdateEventHandler } from './device-map-work-status-update.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { DeviceErrorMapper } from '../mappers/device-error.mapper'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { DeviceModeMapper } from '../mappers/device-mode.mapper'; +import type { DeviceStateMapper } from '../mappers/device-state.mapper'; +import type { DeviceWaterLevelMapper } from '../mappers/device-water-level.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceMapWorkStatusUpdateEventHandler', function () { + let deviceStateMapper: DeviceStateMapper; + let deviceModeMapper: DeviceModeMapper; + let deviceErrorMapper: DeviceErrorMapper; + let deviceBatteryMapper: DeviceBatteryMapper; + let deviceFanSpeedMapper: DeviceFanSpeedMapper; + let deviceWaterLevelMapper: DeviceWaterLevelMapper; + let deviceRepository: DeviceRepository; + let eventHandler: DeviceMapWorkStatusUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_MAPID_WORK_STATUS_PUSH_REQ'>; + let device: Device; + + beforeEach(function () { + deviceStateMapper = imock(); + deviceModeMapper = imock(); + deviceErrorMapper = imock(); + deviceBatteryMapper = imock(); + deviceFanSpeedMapper = imock(); + deviceWaterLevelMapper = imock(); + deviceRepository = imock(); + eventHandler = new DeviceMapWorkStatusUpdateEventHandler( + instance(deviceStateMapper), + instance(deviceModeMapper), + instance(deviceErrorMapper), + instance(deviceBatteryMapper), + instance(deviceFanSpeedMapper), + instance(deviceWaterLevelMapper), + instance(deviceRepository), + ); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_MAPID_WORK_STATUS_PUSH_REQ'); + }); + + describe('#handle()', function () { + it('should update the device map work status', async function () { + const deviceState = new DeviceState(DeviceStateValue.Docked); + const deviceMode = new DeviceMode(DeviceModeValue.None); + const deviceError = new DeviceError(DeviceErrorValue.None); + const deviceBattery = new DeviceBattery(5); + const deviceFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_WORK_STATUS_PUSH_REQ'), + data: { + mapHeadId: 1, + areaCleanFlag: true, + workMode: 0, + battery: 200, + chargeStatus: true, + type: 3, + faultCode: 2105, + cleanPreference: 3, + repeatClean: false, + cleanTime: 0, + cleanSize: 0, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceStateMapper.toDomain(anything())).thenReturn(deviceState); + when(deviceModeMapper.toDomain(anything())).thenReturn(deviceMode); + when(deviceErrorMapper.toDomain(anything())).thenReturn(deviceError); + when(deviceBatteryMapper.toDomain(anything())).thenReturn(deviceBattery); + when(deviceFanSpeedMapper.toDomain(anything())).thenReturn(deviceFanSpeed); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceStateMapper.toDomain(deepEqual({ type: 3, workMode: 0, chargeStatus: true }))).once(); + verify(deviceModeMapper.toDomain(0)).once(); + verify(deviceErrorMapper.toDomain(2105)).once(); + verify(deviceBatteryMapper.toDomain(200)).once(); + verify(deviceFanSpeedMapper.toDomain(3)).once(); + verify( + device.updateCurrentCleanWork( + deepEqual(new DeviceCleanWork({ size: new CleanSize(0), time: DeviceTime.fromMinutes(0) })), + ), + ).once(); + verify(device.updateState(deviceState)).once(); + verify(device.updateMode(deviceMode)).once(); + verify(device.updateError(deviceError)).once(); + verify(device.updateBattery(deviceBattery)).once(); + verify(device.updateFanSpeed(deviceFanSpeed)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + + it('should update optional values of device map work status', async function () { + const deviceState = new DeviceState(DeviceStateValue.Docked); + const deviceMode = new DeviceMode(DeviceModeValue.None); + const deviceError = new DeviceError(DeviceErrorValue.None); + const deviceBattery = new DeviceBattery(5); + const deviceFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const deviceWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Low); + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_WORK_STATUS_PUSH_REQ'), + data: { + mapHeadId: 1, + areaCleanFlag: true, + workMode: 0, + battery: 200, + chargeStatus: true, + type: 3, + faultCode: 2105, + cleanPreference: 3, + repeatClean: false, + cleanTime: 0, + cleanSize: 0, + waterLevel: 12, + dustBoxType: 3, + mopType: false, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceStateMapper.toDomain(anything())).thenReturn(deviceState); + when(deviceModeMapper.toDomain(anything())).thenReturn(deviceMode); + when(deviceErrorMapper.toDomain(anything())).thenReturn(deviceError); + when(deviceBatteryMapper.toDomain(anything())).thenReturn(deviceBattery); + when(deviceFanSpeedMapper.toDomain(anything())).thenReturn(deviceFanSpeed); + when(deviceWaterLevelMapper.toDomain(anything())).thenReturn(deviceWaterLevel); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceWaterLevelMapper.toDomain(12)).once(); + verify(device.updateHasMopAttached(false)).once(); + verify(device.updateWaterLevel(deviceWaterLevel)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.ts new file mode 100644 index 00000000..e2a70cd4 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-map-work-status-update.event-handler.ts @@ -0,0 +1,64 @@ +import { CleanSize, DeviceCleanWork, DeviceTime } from '@agnoc/domain'; +import { isPresent } from '@agnoc/toolkit'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { DeviceBatteryMapper } from '../mappers/device-battery.mapper'; +import type { DeviceErrorMapper } from '../mappers/device-error.mapper'; +import type { DeviceFanSpeedMapper } from '../mappers/device-fan-speed.mapper'; +import type { DeviceModeMapper } from '../mappers/device-mode.mapper'; +import type { DeviceStateMapper } from '../mappers/device-state.mapper'; +import type { DeviceWaterLevelMapper } from '../mappers/device-water-level.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceMapWorkStatusUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_MAPID_WORK_STATUS_PUSH_REQ'; + + constructor( + private readonly deviceStateMapper: DeviceStateMapper, + private readonly deviceModeMapper: DeviceModeMapper, + private readonly deviceErrorMapper: DeviceErrorMapper, + private readonly deviceBatteryMapper: DeviceBatteryMapper, + private readonly deviceFanSpeedMapper: DeviceFanSpeedMapper, + private readonly deviceWaterLevelMapper: DeviceWaterLevelMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(message: PacketMessage<'DEVICE_MAPID_WORK_STATUS_PUSH_REQ'>): Promise { + message.assertDevice(); + + const { + battery, + type, + workMode, + chargeStatus, + cleanPreference, + faultCode, + waterLevel, + mopType, + cleanSize, + cleanTime, + } = message.packet.payload.data; + + message.device.updateCurrentCleanWork( + new DeviceCleanWork({ + size: new CleanSize(cleanSize), + time: DeviceTime.fromMinutes(cleanTime), + }), + ); + message.device.updateState(this.deviceStateMapper.toDomain({ type, workMode, chargeStatus })); + message.device.updateMode(this.deviceModeMapper.toDomain(workMode)); + message.device.updateError(this.deviceErrorMapper.toDomain(faultCode)); + message.device.updateBattery(this.deviceBatteryMapper.toDomain(battery)); + message.device.updateFanSpeed(this.deviceFanSpeedMapper.toDomain(cleanPreference)); + + if (isPresent(mopType)) { + message.device.updateHasMopAttached(mopType); + } + + if (isPresent(waterLevel)) { + message.device.updateWaterLevel(this.deviceWaterLevelMapper.toDomain(waterLevel)); + } + + await this.deviceRepository.saveOne(message.device); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.test.ts new file mode 100644 index 00000000..810a9335 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.test.ts @@ -0,0 +1,24 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceMemoryMapInfoEventHandler } from './device-memory-map-info.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceMemoryMapInfoEventHandler', function () { + let eventHandler: DeviceMemoryMapInfoEventHandler; + let packetMessage: PacketMessage<'DEVICE_MAPID_PUSH_ALL_MEMORY_MAP_INFO'>; + + beforeEach(function () { + eventHandler = new DeviceMemoryMapInfoEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_MAPID_PUSH_ALL_MEMORY_MAP_INFO'); + }); + + describe('#handle()', function () { + it('should do nothing', async function () { + await eventHandler.handle(instance(packetMessage)); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.ts new file mode 100644 index 00000000..4525b2cc --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-memory-map-info.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceMemoryMapInfoEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_MAPID_PUSH_ALL_MEMORY_MAP_INFO'; + + async handle(_: PacketMessage<'DEVICE_MAPID_PUSH_ALL_MEMORY_MAP_INFO'>): Promise { + // TODO: save device memory map info + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.test.ts new file mode 100644 index 00000000..ea58d56b --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.test.ts @@ -0,0 +1,63 @@ +import { DeviceNetwork } from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { capture, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceNetworkUpdateEventHandler } from './device-network-update.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceNetworkUpdateEventHandler', function () { + let deviceRepository: DeviceRepository; + let eventHandler: DeviceNetworkUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_WLAN_INFO_GETTING_RSP'>; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + eventHandler = new DeviceNetworkUpdateEventHandler(instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_WLAN_INFO_GETTING_RSP'); + }); + + describe('#handle()', function () { + it('should update the device network', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_WLAN_INFO_GETTING_RSP'), + data: { + result: 0, + body: { + ipv4: '127.0.0.1', + ssid: 'ssid', + port: 1234, + mask: '255.255.255.255', + mac: '00:00:00:00:00:00', + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + const [deviceNetwork] = capture(device.updateNetwork).first(); + + expect(deviceNetwork).to.be.instanceOf(DeviceNetwork); + expect(deviceNetwork.ipv4).to.be.equal('127.0.0.1'); + expect(deviceNetwork.ssid).to.be.equal('ssid'); + expect(deviceNetwork.port).to.be.equal(1234); + expect(deviceNetwork.mask).to.be.equal('255.255.255.255'); + expect(deviceNetwork.mac).to.be.equal('00:00:00:00:00:00'); + + verify(packetMessage.assertDevice()).once(); + verify(device.updateNetwork(deviceNetwork)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.ts new file mode 100644 index 00000000..39c31b63 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-network-update.event-handler.ts @@ -0,0 +1,28 @@ +import { DeviceNetwork } from '@agnoc/domain'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceNetworkUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_WLAN_INFO_GETTING_RSP'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(message: PacketMessage<'DEVICE_WLAN_INFO_GETTING_RSP'>): Promise { + message.assertDevice(); + + const data = message.packet.payload.data.body; + + message.device.updateNetwork( + new DeviceNetwork({ + ipv4: data.ipv4, + ssid: data.ssid, + port: data.port, + mask: data.mask, + mac: data.mac, + }), + ); + + await this.deviceRepository.saveOne(message.device); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.test.ts new file mode 100644 index 00000000..6774fce9 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.test.ts @@ -0,0 +1,24 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceOfflineEventHandler } from './device-offline.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceOfflineEventHandler', function () { + let eventHandler: DeviceOfflineEventHandler; + let packetMessage: PacketMessage<'DEVICE_OFFLINE_CMD'>; + + beforeEach(function () { + eventHandler = new DeviceOfflineEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_OFFLINE_CMD'); + }); + + describe('#handle()', function () { + it('should do nothing', async function () { + await eventHandler.handle(instance(packetMessage)); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.ts new file mode 100644 index 00000000..fab22059 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-offline.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceOfflineEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_OFFLINE_CMD'; + + async handle(_: PacketMessage<'DEVICE_OFFLINE_CMD'>): Promise { + // TODO: Should do something here? + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.test.ts new file mode 100644 index 00000000..1774ed64 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.test.ts @@ -0,0 +1,76 @@ +import { DeviceOrder } from '@agnoc/domain'; +import { givenSomeDeviceOrderProps } from '@agnoc/domain/test-support'; +import { Payload, OPCode, Packet } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceOrderListUpdateEventHandler } from './device-order-list-update.event-handler'; +import type { DeviceOrderMapper } from '../mappers/device-order.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceOrderListUpdateEventHandler', function () { + let deviceOrderMapper: DeviceOrderMapper; + let deviceRepository: DeviceRepository; + let eventHandler: DeviceOrderListUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_ORDERLIST_GETTING_RSP'>; + let device: Device; + + beforeEach(function () { + deviceOrderMapper = imock(); + deviceRepository = imock(); + eventHandler = new DeviceOrderListUpdateEventHandler(instance(deviceOrderMapper), instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_ORDERLIST_GETTING_RSP'); + }); + + describe('#handle()', function () { + it('should not update the device orders when it is empty', async function () { + const deviceOrder = new DeviceOrder(givenSomeDeviceOrderProps()); + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_ORDERLIST_GETTING_RSP'), + data: { result: 0 }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceOrderMapper.toDomain(anything())).thenReturn(deviceOrder); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceOrderMapper.toDomain(anything())).never(); + verify(device.updateOrders(anything())).never(); + verify(deviceRepository.saveOne(instance(device))).never(); + }); + + it('should update the device orders when it has orders', async function () { + const deviceOrder = new DeviceOrder(givenSomeDeviceOrderProps()); + const order = { orderId: 1, enable: true, repeat: false, weekDay: 1, dayTime: 180 }; + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_ORDERLIST_GETTING_RSP'), + data: { + result: 0, + orderList: [order], + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(deviceOrderMapper.toDomain(anything())).thenReturn(deviceOrder); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.assertDevice()).once(); + verify(deviceOrderMapper.toDomain(order)).once(); + verify(device.updateOrders(deepEqual([deviceOrder]))).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.ts new file mode 100644 index 00000000..aa2bb07e --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-order-list-update.event-handler.ts @@ -0,0 +1,27 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { DeviceOrderMapper } from '../mappers/device-order.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceOrderListUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_ORDERLIST_GETTING_RSP'; + + constructor( + private readonly deviceOrderMapper: DeviceOrderMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(message: PacketMessage<'DEVICE_ORDERLIST_GETTING_RSP'>): Promise { + message.assertDevice(); + + const data = message.packet.payload.data; + + if (data.orderList) { + const deviceOrders = data.orderList?.map((order) => this.deviceOrderMapper.toDomain(order)); + + message.device.updateOrders(deviceOrders); + + await this.deviceRepository.saveOne(message.device); + } + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.test.ts new file mode 100644 index 00000000..63fe95bd --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.test.ts @@ -0,0 +1,63 @@ +import { Device } from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { capture, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceRegisterEventHandler } from './device-register.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +describe('DeviceRegisterEventHandler', function () { + let eventHandler: DeviceRegisterEventHandler; + let packetMessage: PacketMessage<'DEVICE_REGISTER_REQ'>; + let deviceRepository: DeviceRepository; + + beforeEach(function () { + deviceRepository = imock(); + eventHandler = new DeviceRegisterEventHandler(instance(deviceRepository)); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_REGISTER_REQ'); + }); + + describe('#handle()', function () { + it('should create a device', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_REGISTER_REQ'), + data: { + deviceType: 9, + deviceSerialNumber: '1234567890', + softwareVersion: '1.0.0', + hardwareVersion: '1.0.0', + deviceMac: '00:00:00:00:00:00', + customerFirmwareId: 1, + ctrlVersion: '1.0.0', + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + const [device] = capture(deviceRepository.saveOne).first(); + + expect(device).to.be.instanceOf(Device); + expect(device.id).to.exist; + expect(device.userId).to.exist; + expect(device.system.type).to.be.equal(9); + expect(device.system.serialNumber).to.be.equal('1234567890'); + expect(device.version.software).to.be.equal('1.0.0'); + expect(device.version.hardware).to.be.equal('1.0.0'); + expect(device.battery.value).to.be.equal(100); + expect(device.isConnected).to.be.false; + expect(device.isLocked).to.be.false; + + verify( + packetMessage.respond('DEVICE_REGISTER_RSP', deepEqual({ result: 0, device: { id: device.id.value } })), + ).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.ts new file mode 100644 index 00000000..2ec184de --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-register.event-handler.ts @@ -0,0 +1,30 @@ +import { Device, DeviceBattery, DeviceBatteryMaxValue, DeviceSystem, DeviceVersion } from '@agnoc/domain'; +import { ID } from '@agnoc/toolkit'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceRegisterEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_REGISTER_REQ'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(message: PacketMessage<'DEVICE_REGISTER_REQ'>): Promise { + const data = message.packet.payload.data; + const device = new Device({ + id: ID.generate(), + userId: ID.generate(), + system: new DeviceSystem({ type: data.deviceType, serialNumber: data.deviceSerialNumber }), + version: new DeviceVersion({ software: data.softwareVersion, hardware: data.hardwareVersion }), + battery: new DeviceBattery(DeviceBatteryMaxValue), + isConnected: false, + isLocked: false, + }); + + await this.deviceRepository.saveOne(device); + + const response = { result: 0, device: { id: device.id.value } }; + + await message.respond('DEVICE_REGISTER_RSP', response); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.test.ts new file mode 100644 index 00000000..943b3fcb --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.test.ts @@ -0,0 +1,161 @@ +import { DeviceSetting, DeviceSettings, DeviceTime, QuietHoursSetting, VoiceSetting } from '@agnoc/domain'; +import { givenSomeVoiceSettingProps } from '@agnoc/domain/test-support'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, capture, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceSettingsUpdateEventHandler } from './device-settings-update.event-handler'; +import type { VoiceSettingMapper } from '../mappers/voice-setting.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceSettingsUpdateEventHandler', function () { + let voiceSettingMapper: VoiceSettingMapper; + let deviceRepository: DeviceRepository; + let eventHandler: DeviceSettingsUpdateEventHandler; + let packetMessage: PacketMessage<'PUSH_DEVICE_AGENT_SETTING_REQ'>; + let device: Device; + + beforeEach(function () { + voiceSettingMapper = imock(); + deviceRepository = imock(); + eventHandler = new DeviceSettingsUpdateEventHandler(instance(voiceSettingMapper), instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('PUSH_DEVICE_AGENT_SETTING_REQ'); + }); + + describe('#handle()', function () { + it('should update the device settings', async function () { + const voiceSetting = new VoiceSetting(givenSomeVoiceSettingProps()); + const payload = new Payload({ + opcode: OPCode.fromName('PUSH_DEVICE_AGENT_SETTING_REQ'), + data: { + deviceId: 1, + voice: { + voiceMode: true, + volume: 2, + }, + quietHours: { + isOpen: true, + beginTime: 0, + endTime: 180, + }, + cleanPreference: {}, + ota: { + forceupgrade: false, + newVersion: false, + otaPackageVersion: '1.0.0', + packageSize: '0', + remoteUrl: '', + systemVersion: '1.0.0', + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(voiceSettingMapper.toDomain(anything())).thenReturn(voiceSetting); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + const [deviceSettings] = capture(device.updateSettings).first(); + + expect(deviceSettings).to.be.instanceOf(DeviceSettings); + expect(deviceSettings.voice).to.equal(voiceSetting); + expect( + deviceSettings.quietHours.equals( + new QuietHoursSetting({ + isEnabled: true, + beginTime: new DeviceTime({ hours: 0, minutes: 0 }), + endTime: new DeviceTime({ hours: 3, minutes: 0 }), + }), + ), + ).to.be.true; + expect(deviceSettings.ecoMode.equals(new DeviceSetting({ isEnabled: false }))).to.be.true; + expect(deviceSettings.repeatClean.equals(new DeviceSetting({ isEnabled: false }))).to.be.true; + expect(deviceSettings.brokenClean.equals(new DeviceSetting({ isEnabled: false }))).to.be.true; + expect(deviceSettings.carpetMode.equals(new DeviceSetting({ isEnabled: false }))).to.be.true; + expect(deviceSettings.historyMap.equals(new DeviceSetting({ isEnabled: false }))).to.be.true; + + verify(packetMessage.assertDevice()).once(); + verify(voiceSettingMapper.toDomain(deepEqual({ isEnabled: true, volume: 2 }))).once(); + verify(device.updateSettings(deviceSettings)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + verify(packetMessage.respond('PUSH_DEVICE_AGENT_SETTING_RSP', deepEqual({ result: 0 }))).once(); + }); + + it('should update the optional values of device settings', async function () { + const voiceSetting = new VoiceSetting(givenSomeVoiceSettingProps()); + const payload = new Payload({ + opcode: OPCode.fromName('PUSH_DEVICE_AGENT_SETTING_REQ'), + data: { + deviceId: 1, + voice: { + voiceMode: true, + volume: 2, + }, + quietHours: { + isOpen: true, + beginTime: 0, + endTime: 180, + }, + cleanPreference: { + ecoMode: true, + repeatClean: true, + cleanBroken: true, + carpetTurbo: true, + historyMap: true, + }, + ota: { + forceupgrade: false, + newVersion: false, + otaPackageVersion: '1.0.0', + packageSize: '0', + remoteUrl: '', + systemVersion: '1.0.0', + }, + taskList: { + enable: true, + dayTime: 0, + orderId: 0, + repeat: false, + weekDay: 0, + cleanInfo: { + mapHeadId: 0, + cleanMode: 0, + planId: 0, + twiceClean: false, + windPower: 0, + waterLevel: 0, + }, + }, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(voiceSettingMapper.toDomain(anything())).thenReturn(voiceSetting); + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + const [deviceSettings] = capture(device.updateSettings).first(); + + expect(deviceSettings).to.be.instanceOf(DeviceSettings); + expect(deviceSettings.ecoMode.equals(new DeviceSetting({ isEnabled: true }))).to.be.true; + expect(deviceSettings.repeatClean.equals(new DeviceSetting({ isEnabled: true }))).to.be.true; + expect(deviceSettings.brokenClean.equals(new DeviceSetting({ isEnabled: true }))).to.be.true; + expect(deviceSettings.carpetMode.equals(new DeviceSetting({ isEnabled: true }))).to.be.true; + expect(deviceSettings.historyMap.equals(new DeviceSetting({ isEnabled: true }))).to.be.true; + + verify(packetMessage.assertDevice()).once(); + verify(device.updateSettings(deviceSettings)).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.ts new file mode 100644 index 00000000..5c79d116 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-settings-update.event-handler.ts @@ -0,0 +1,47 @@ +import { DeviceSetting, DeviceSettings, DeviceTime, QuietHoursSetting } from '@agnoc/domain'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { VoiceSettingMapper } from '../mappers/voice-setting.mapper'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceSettingsUpdateEventHandler implements PacketEventHandler { + readonly forName = 'PUSH_DEVICE_AGENT_SETTING_REQ'; + + constructor( + private readonly voiceSettingMapper: VoiceSettingMapper, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(message: PacketMessage<'PUSH_DEVICE_AGENT_SETTING_REQ'>): Promise { + message.assertDevice(); + + const data = message.packet.payload.data; + const deviceSettings = new DeviceSettings({ + voice: this.voiceSettingMapper.toDomain({ + isEnabled: data.voice.voiceMode, + volume: data.voice.volume, + }), + quietHours: new QuietHoursSetting({ + isEnabled: data.quietHours.isOpen, + beginTime: DeviceTime.fromMinutes(data.quietHours.beginTime), + endTime: DeviceTime.fromMinutes(data.quietHours.endTime), + }), + ecoMode: new DeviceSetting({ isEnabled: data.cleanPreference.ecoMode ?? false }), + repeatClean: new DeviceSetting({ isEnabled: data.cleanPreference.repeatClean ?? false }), + brokenClean: new DeviceSetting({ isEnabled: data.cleanPreference.cleanBroken ?? false }), + carpetMode: new DeviceSetting({ isEnabled: data.cleanPreference.carpetTurbo ?? false }), + historyMap: new DeviceSetting({ isEnabled: data.cleanPreference.historyMap ?? false }), + }); + + // TODO: Fields below are unused + // - data.deviceId + // - data.ota + // - data.taskList + + message.device.updateSettings(deviceSettings); + + await this.deviceRepository.saveOne(message.device); + + await message.respond('PUSH_DEVICE_AGENT_SETTING_RSP', { result: 0 }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.test.ts new file mode 100644 index 00000000..e7e3da7d --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.test.ts @@ -0,0 +1,24 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceTimeUpdateEventHandler } from './device-time-update.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceTimeUpdateEventHandler', function () { + let eventHandler: DeviceTimeUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_GETTIME_RSP'>; + + beforeEach(function () { + eventHandler = new DeviceTimeUpdateEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_GETTIME_RSP'); + }); + + describe('#handle()', function () { + it('should do nothing', async function () { + await eventHandler.handle(instance(packetMessage)); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.ts new file mode 100644 index 00000000..8fa32242 --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-time-update.event-handler.ts @@ -0,0 +1,10 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceTimeUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_GETTIME_RSP'; + + async handle(_: PacketMessage<'DEVICE_GETTIME_RSP'>): Promise { + // TODO: Should do something here? + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.test.ts new file mode 100644 index 00000000..d33e1f4d --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.test.ts @@ -0,0 +1,43 @@ +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceUpgradeInfoEventHandler } from './device-upgrade-info.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +describe('DeviceUpgradeInfoEventHandler', function () { + let eventHandler: DeviceUpgradeInfoEventHandler; + let packetMessage: PacketMessage<'PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'>; + + beforeEach(function () { + eventHandler = new DeviceUpgradeInfoEventHandler(); + packetMessage = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'); + }); + + describe('#handle()', function () { + it('should update the device battery', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'), + data: { + forceupgrade: false, + newVersion: false, + otaPackageVersion: '1.0.0', + packageSize: '0', + remoteUrl: '', + systemVersion: '1.0.0', + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + + await eventHandler.handle(instance(packetMessage)); + + verify(packetMessage.respond('PUSH_DEVICE_PACKAGE_UPGRADE_INFO_RSP', deepEqual({ result: 0 }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.ts new file mode 100644 index 00000000..f6556f1b --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-upgrade-info.event-handler.ts @@ -0,0 +1,12 @@ +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; + +export class DeviceUpgradeInfoEventHandler implements PacketEventHandler { + readonly forName = 'PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'; + + async handle(message: PacketMessage<'PUSH_DEVICE_PACKAGE_UPGRADE_INFO_REQ'>): Promise { + // TODO: save device ota info + + await message.respond('PUSH_DEVICE_PACKAGE_UPGRADE_INFO_RSP', { result: 0 }); + } +} diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.test.ts b/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.test.ts new file mode 100644 index 00000000..49c8a80d --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.test.ts @@ -0,0 +1,51 @@ +import { DeviceVersion } from '@agnoc/domain'; +import { OPCode, Packet, Payload } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { capture, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceVersionUpdateEventHandler } from './device-version-update.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceRepository } from '@agnoc/domain'; + +describe('DeviceVersionUpdateEventHandler', function () { + let deviceRepository: DeviceRepository; + let eventHandler: DeviceVersionUpdateEventHandler; + let packetMessage: PacketMessage<'DEVICE_VERSION_INFO_UPDATE_REQ'>; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + eventHandler = new DeviceVersionUpdateEventHandler(instance(deviceRepository)); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(eventHandler.forName).to.be.equal('DEVICE_VERSION_INFO_UPDATE_REQ'); + }); + + describe('#handle()', function () { + it('should update the device version', async function () { + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_VERSION_INFO_UPDATE_REQ'), + data: { softwareVersion: '1.0.0', hardwareVersion: '1.0.0' }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + + when(packetMessage.packet).thenReturn(packet); + when(packetMessage.device).thenReturn(instance(device)); + + await eventHandler.handle(instance(packetMessage)); + + const [deviceVersion] = capture(device.updateVersion).first(); + + expect(deviceVersion).to.be.instanceOf(DeviceVersion); + expect(deviceVersion.software).to.be.equal('1.0.0'); + expect(deviceVersion.hardware).to.be.equal('1.0.0'); + + verify(packetMessage.assertDevice()).once(); + verify(deviceRepository.saveOne(instance(device))).once(); + verify(packetMessage.respond('DEVICE_VERSION_INFO_UPDATE_RSP', deepEqual({ result: 0 }))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.ts b/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.ts new file mode 100644 index 00000000..3406078c --- /dev/null +++ b/packages/adapter-tcp/src/packet-event-handlers/device-version-update.event-handler.ts @@ -0,0 +1,22 @@ +import { DeviceVersion } from '@agnoc/domain'; +import type { PacketEventHandler } from '../base-classes/packet.event-handler'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceRepository } from '@agnoc/domain'; + +export class DeviceVersionUpdateEventHandler implements PacketEventHandler { + readonly forName = 'DEVICE_VERSION_INFO_UPDATE_REQ'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(message: PacketMessage<'DEVICE_VERSION_INFO_UPDATE_REQ'>): Promise { + message.assertDevice(); + + const data = message.packet.payload.data; + + message.device.updateVersion(new DeviceVersion({ software: data.softwareVersion, hardware: data.hardwareVersion })); + + await this.deviceRepository.saveOne(message.device); + + await message.respond('DEVICE_VERSION_INFO_UPDATE_RSP', { result: 0 }); + } +} diff --git a/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.test.ts b/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.test.ts new file mode 100644 index 00000000..b484fdf2 --- /dev/null +++ b/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.test.ts @@ -0,0 +1,98 @@ +import { DeviceConsumable, DeviceConsumableType, GetDeviceConsumablesQuery } from '@agnoc/domain'; +import { DomainException, ID } from '@agnoc/toolkit'; +import { Payload, OPCode, Packet } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { GetDeviceConsumablesQueryHandler } from './get-device-consumables.query-handler'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { ConnectionWithDevice, DeviceRepository, Device } from '@agnoc/domain'; + +describe('GetDeviceConsumablesQueryHandler', function () { + let deviceRepository: DeviceRepository; + let packetConnectionFinderService: PacketConnectionFinderService; + let queryHandler: GetDeviceConsumablesQueryHandler; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + deviceRepository = imock(); + packetConnectionFinderService = imock(); + queryHandler = new GetDeviceConsumablesQueryHandler( + instance(packetConnectionFinderService), + instance(deviceRepository), + ); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + it('should define the name', function () { + expect(queryHandler.forName).to.be.equal('GetDeviceConsumablesQuery'); + }); + + describe('#handle()', function () { + it('should throw an error when no connection is found', async function () { + const query = new GetDeviceConsumablesQuery({ deviceId: new ID(1) }); + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(undefined); + + await expect(queryHandler.handle(query)).to.be.rejectedWith( + DomainException, + 'Unable to find a device connected with id 1', + ); + + verify(packetConnection.sendAndWait(anything(), anything())).never(); + verify(packetMessage.assertPayloadName(anything())).never(); + }); + + it('should get consumables', async function () { + const query = new GetDeviceConsumablesQuery({ deviceId: new ID(1) }); + const payload = new Payload({ + opcode: OPCode.fromName('DEVICE_MAPID_GET_CONSUMABLES_PARAM_REQ'), + data: { + mainBrushTime: 1, + sideBrushTime: 2, + filterTime: 3, + dishclothTime: 4, + }, + }); + const packet = new Packet({ ...givenSomePacketProps(), payload }); + const consumables = [ + new DeviceConsumable({ + type: DeviceConsumableType.MainBrush, + hoursUsed: 1, + }), + new DeviceConsumable({ + type: DeviceConsumableType.SideBrush, + hoursUsed: 2, + }), + new DeviceConsumable({ + type: DeviceConsumableType.Filter, + hoursUsed: 3, + }), + new DeviceConsumable({ + type: DeviceConsumableType.Dishcloth, + hoursUsed: 4, + }), + ]; + + when(packetConnectionFinderService.findByDeviceId(anything())).thenResolve(instance(packetConnection)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetMessage.packet).thenReturn(packet); + when(packetConnection.device).thenReturn(instance(device)); + + const ret = await queryHandler.handle(query); + + expect(ret.consumables).to.be.deep.equal(consumables); + + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_CONSUMABLES_PARAM_REQ', deepEqual({}))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_CONSUMABLES_PARAM_RSP')).once(); + verify(device.updateConsumables(deepEqual(consumables))).once(); + verify(deviceRepository.saveOne(anything())).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.ts b/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.ts new file mode 100644 index 00000000..aaa2117c --- /dev/null +++ b/packages/adapter-tcp/src/query-handlers/get-device-consumables.query-handler.ts @@ -0,0 +1,72 @@ +import { DeviceConsumable, DeviceConsumableType } from '@agnoc/domain'; +import { DomainException } from '@agnoc/toolkit'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PacketConnectionFinderService } from '../services/packet-connection-finder.service'; +import type { + GetDeviceConsumablesQuery, + GetDeviceConsumablesQueryOutput, + QueryHandler, + DeviceRepository, + ConnectionWithDevice, + Device, +} from '@agnoc/domain'; + +export class GetDeviceConsumablesQueryHandler implements QueryHandler { + readonly forName = 'GetDeviceConsumablesQuery'; + + constructor( + private readonly packetConnectionFinderService: PacketConnectionFinderService, + private readonly deviceRepository: DeviceRepository, + ) {} + + async handle(event: GetDeviceConsumablesQuery): Promise { + const connection = await this.packetConnectionFinderService.findByDeviceId(event.deviceId); + + if (!connection) { + throw new DomainException(`Unable to find a device connected with id ${event.deviceId.value}`); + } + + const consumables = await this.getConsumables(connection); + + await this.updateDeviceConsumables(connection, consumables); + + return { consumables }; + } + + private async getConsumables(connection: PacketConnection & ConnectionWithDevice) { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_GET_CONSUMABLES_PARAM_REQ', {}); + + response.assertPayloadName('DEVICE_MAPID_GET_CONSUMABLES_PARAM_RSP'); + + const data = response.packet.payload.data; + const consumables = [ + new DeviceConsumable({ + type: DeviceConsumableType.MainBrush, + hoursUsed: data.mainBrushTime, + }), + new DeviceConsumable({ + type: DeviceConsumableType.SideBrush, + hoursUsed: data.sideBrushTime, + }), + new DeviceConsumable({ + type: DeviceConsumableType.Filter, + hoursUsed: data.filterTime, + }), + new DeviceConsumable({ + type: DeviceConsumableType.Dishcloth, + hoursUsed: data.dishclothTime, + }), + ]; + return consumables; + } + + private async updateDeviceConsumables( + connection: PacketConnection & ConnectionWithDevice, + consumables: DeviceConsumable[], + ) { + connection.device.updateConsumables(consumables); + + await this.deviceRepository.saveOne(connection.device); + } +} diff --git a/packages/adapter-tcp/src/services/connection-device-updater.service.test.ts b/packages/adapter-tcp/src/services/connection-device-updater.service.test.ts new file mode 100644 index 00000000..69205a99 --- /dev/null +++ b/packages/adapter-tcp/src/services/connection-device-updater.service.test.ts @@ -0,0 +1,64 @@ +import { Device } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { Packet } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { ConnectionDeviceUpdaterService } from './connection-device-updater.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { ConnectionRepository, DeviceRepository } from '@agnoc/domain'; + +describe('ConnectionDeviceUpdaterService', function () { + let connectionRepository: ConnectionRepository; + let deviceRepository: DeviceRepository; + let service: ConnectionDeviceUpdaterService; + let connection: PacketConnection; + + beforeEach(function () { + connectionRepository = imock(); + deviceRepository = imock(); + connection = imock(); + service = new ConnectionDeviceUpdaterService(instance(connectionRepository), instance(deviceRepository)); + }); + + describe('#updateFromPacket()', function () { + it('should update device in connection from packet when ids does not match', async function () { + const device = new Device(givenSomeDeviceProps()); + const packet = new Packet({ ...givenSomePacketProps(), deviceId: new ID(1) }); + + when(connection.device).thenReturn(new Device({ ...givenSomeDeviceProps(), id: new ID(2) })); + when(deviceRepository.findOneById(anything())).thenResolve(device); + + await service.updateFromPacket(packet, instance(connection)); + + verify(connection.setDevice(device)).once(); + verify(connectionRepository.saveOne(instance(connection))).once(); + }); + + it('should not update device in connection from packet when ids match', async function () { + const device = new Device(givenSomeDeviceProps()); + const packet = new Packet({ ...givenSomePacketProps(), deviceId: new ID(1) }); + + when(connection.device).thenReturn(new Device({ ...givenSomeDeviceProps(), id: new ID(1) })); + when(deviceRepository.findOneById(anything())).thenResolve(device); + + await service.updateFromPacket(packet, instance(connection)); + + verify(connection.setDevice(anything())).never(); + verify(connectionRepository.saveOne(anything())).never(); + }); + + it('should update device in connection with nothing when packet device id is zero', async function () { + const device = new Device(givenSomeDeviceProps()); + const packet = new Packet({ ...givenSomePacketProps(), deviceId: new ID(0) }); + + when(connection.device).thenReturn(new Device({ ...givenSomeDeviceProps(), id: new ID(1) })); + when(deviceRepository.findOneById(anything())).thenResolve(device); + + await service.updateFromPacket(packet, instance(connection)); + + verify(connection.setDevice(undefined)).once(); + verify(connectionRepository.saveOne(instance(connection))).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/services/connection-device-updater.service.ts b/packages/adapter-tcp/src/services/connection-device-updater.service.ts new file mode 100644 index 00000000..8a70026b --- /dev/null +++ b/packages/adapter-tcp/src/services/connection-device-updater.service.ts @@ -0,0 +1,29 @@ +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { ConnectionRepository, DeviceRepository, Device } from '@agnoc/domain'; +import type { ID } from '@agnoc/toolkit'; +import type { Packet } from '@agnoc/transport-tcp'; + +export class ConnectionDeviceUpdaterService { + constructor( + private readonly connectionRepository: ConnectionRepository, + private readonly deviceRepository: DeviceRepository, + ) {} + + async updateFromPacket(packet: Packet, connection: PacketConnection): Promise { + if (!packet.deviceId.equals(connection.device?.id)) { + const device = await this.findDeviceById(packet.deviceId); + + connection.setDevice(device); + + await this.connectionRepository.saveOne(connection); + } + } + + private async findDeviceById(id: ID): Promise { + if (id.value === 0) { + return undefined; + } + + return this.deviceRepository.findOneById(id); + } +} diff --git a/packages/adapter-tcp/src/services/device-cleaning.service.test.ts b/packages/adapter-tcp/src/services/device-cleaning.service.test.ts new file mode 100644 index 00000000..e6dc762e --- /dev/null +++ b/packages/adapter-tcp/src/services/device-cleaning.service.test.ts @@ -0,0 +1,113 @@ +import { DeviceMap, MapPosition } from '@agnoc/domain'; +import { givenSomeDeviceMapProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { DeviceCleaningService } from './device-cleaning.service'; +import { ModeCtrlValue } from './device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { ConnectionWithDevice, Device } from '@agnoc/domain'; + +describe('DeviceCleaningService', function () { + let service: DeviceCleaningService; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: Device; + + beforeEach(function () { + service = new DeviceCleaningService(); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + describe('#autoCleaning()', function () { + it('should start auto cleaning', async function () { + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + + await service.autoCleaning(instance(packetConnection), ModeCtrlValue.Start); + + verify(packetConnection.sendAndWait('DEVICE_AUTO_CLEAN_REQ', deepEqual({ ctrlValue: 1, cleanType: 2 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_AUTO_CLEAN_RSP')).once(); + }); + }); + + describe('#mopCleaning()', function () { + it('should start mop cleaning', async function () { + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + + await service.mopCleaning(instance(packetConnection), ModeCtrlValue.Start); + + verify(packetConnection.sendAndWait('DEVICE_MOP_FLOOR_CLEAN_REQ', deepEqual({ ctrlValue: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MOP_FLOOR_CLEAN_RSP')).once(); + }); + }); + + describe('#spotCleaning()', function () { + it('should start spot cleaning with map id', async function () { + const deviceMap = new DeviceMap({ ...givenSomeDeviceMapProps(), id: new ID(1) }); + + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.map).thenReturn(deviceMap); + + await service.spotCleaning( + instance(packetConnection), + new MapPosition({ x: 1, y: 2, phi: 3 }), + ModeCtrlValue.Start, + ); + + verify( + packetConnection.sendAndWait( + 'DEVICE_MAPID_SET_NAVIGATION_REQ', + deepEqual({ + mapHeadId: 1, + poseX: 1, + poseY: 2, + posePhi: 3, + ctrlValue: 1, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_NAVIGATION_RSP')).once(); + }); + + it('should start spot cleaning with no map id', async function () { + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.map).thenReturn(undefined); + + await service.spotCleaning( + instance(packetConnection), + new MapPosition({ x: 1, y: 2, phi: 3 }), + ModeCtrlValue.Start, + ); + + verify( + packetConnection.sendAndWait( + 'DEVICE_MAPID_SET_NAVIGATION_REQ', + deepEqual({ + mapHeadId: 0, + poseX: 1, + poseY: 2, + posePhi: 3, + ctrlValue: 1, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_NAVIGATION_RSP')).once(); + }); + }); + + describe('#zoneCleaning()', function () { + it('should start zone cleaning', async function () { + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + + await service.zoneCleaning(instance(packetConnection), ModeCtrlValue.Start); + + verify(packetConnection.sendAndWait('DEVICE_AREA_CLEAN_REQ', deepEqual({ ctrlValue: 1 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_AREA_CLEAN_RSP')).once(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/services/device-cleaning.service.ts b/packages/adapter-tcp/src/services/device-cleaning.service.ts new file mode 100644 index 00000000..5b37a1a9 --- /dev/null +++ b/packages/adapter-tcp/src/services/device-cleaning.service.ts @@ -0,0 +1,47 @@ +import type { ModeCtrlValue } from './device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { ConnectionWithDevice, MapPosition } from '@agnoc/domain'; + +export class DeviceCleaningService { + async zoneCleaning(connection: PacketConnection & ConnectionWithDevice, mode: ModeCtrlValue): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_AREA_CLEAN_REQ', { + ctrlValue: mode, + }); + + response.assertPayloadName('DEVICE_AREA_CLEAN_RSP'); + } + + async spotCleaning( + connection: PacketConnection & ConnectionWithDevice, + position: MapPosition, + mode: ModeCtrlValue, + ): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_SET_NAVIGATION_REQ', { + mapHeadId: connection.device.map?.id.value ?? 0, + poseX: position.x, + poseY: position.y, + posePhi: position.phi, + ctrlValue: mode, + }); + + response.assertPayloadName('DEVICE_MAPID_SET_NAVIGATION_RSP'); + } + + async mopCleaning(connection: PacketConnection & ConnectionWithDevice, mode: ModeCtrlValue): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MOP_FLOOR_CLEAN_REQ', { + ctrlValue: mode, + }); + + response.assertPayloadName('DEVICE_MOP_FLOOR_CLEAN_RSP'); + } + + async autoCleaning(connection: PacketConnection & ConnectionWithDevice, mode: ModeCtrlValue): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_AUTO_CLEAN_REQ', { + ctrlValue: mode, + cleanType: 2, + }); + + response.assertPayloadName('DEVICE_AUTO_CLEAN_RSP'); + } +} diff --git a/packages/adapter-tcp/src/services/device-map.service.test.ts b/packages/adapter-tcp/src/services/device-map.service.test.ts new file mode 100644 index 00000000..74231161 --- /dev/null +++ b/packages/adapter-tcp/src/services/device-map.service.test.ts @@ -0,0 +1,117 @@ +import { DeviceMap, MapCoordinate, Room, Zone } from '@agnoc/domain'; +import { givenSomeDeviceMapProps, givenSomeRoomProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { imock, instance, when, verify, anything, deepEqual } from '@johanblumenberg/ts-mockito'; +import { DeviceMapService } from './device-map.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { ConnectionWithDevice, Device } from '@agnoc/domain'; + +describe('DeviceMapService', function () { + let service: DeviceMapService; + let packetConnection: PacketConnection & ConnectionWithDevice; + let packetMessage: PacketMessage; + let device: DeviceWithMap; + + beforeEach(function () { + service = new DeviceMapService(); + packetConnection = imock(); + packetMessage = imock(); + device = imock(); + }); + + describe('#setMapZones()', function () { + it('should set map zones with map id', async function () { + const deviceMap = new DeviceMap({ ...givenSomeDeviceMapProps(), id: new ID(1) }); + const zones = [ + new Zone({ + id: new ID(1), + coordinates: [new MapCoordinate({ x: 1, y: 2 }), new MapCoordinate({ x: 3, y: 4 })], + }), + new Zone({ + id: new ID(2), + coordinates: [new MapCoordinate({ x: 5, y: 6 }), new MapCoordinate({ x: 7, y: 8 })], + }), + ]; + + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.map).thenReturn(deviceMap); + + await service.setMapZones(instance(packetConnection), zones); + + verify( + packetConnection.sendAndWait( + 'DEVICE_MAPID_SET_AREA_CLEAN_INFO_REQ', + deepEqual({ + mapHeadId: 1, + planId: 0, + cleanAreaLength: 2, + cleanAreaList: [ + { + cleanAreaId: 1, + type: 0, + coordinateLength: 2, + coordinateList: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + }, + { + cleanAreaId: 2, + type: 0, + coordinateLength: 2, + coordinateList: [ + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ], + }, + ], + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_AREA_CLEAN_INFO_RSP')).once(); + }); + }); + + describe('#enableWholeClean()', function () { + it('should enable whole clean', async function () { + const deviceMap = new DeviceMap({ + ...givenSomeDeviceMapProps(), + id: new ID(1), + rooms: [new Room({ ...givenSomeRoomProps(), id: new ID(1), name: 'Room 1' })], + restrictedZones: [new Zone({ id: new ID(1), coordinates: [new MapCoordinate({ x: 0, y: 0 })] })], + }); + + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(packetConnection.device).thenReturn(instance(device)); + when(device.map).thenReturn(deviceMap); + + await service.enableWholeClean(instance(packetConnection)); + + verify( + packetConnection.sendAndWait( + 'DEVICE_MAPID_SET_PLAN_PARAMS_REQ', + deepEqual({ + mapHeadId: 1, + mapName: 'Default', + planId: 2, + planName: 'Default', + roomList: [{ roomId: 1, roomName: 'Room 1', enable: true }], + areaInfo: { + mapHeadId: 1, + planId: 2, + cleanAreaLength: 1, + cleanAreaList: [{ cleanAreaId: 1, type: 0, coordinateLength: 1, coordinateList: [{ x: 0, y: 0 }] }], + }, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_SET_PLAN_PARAMS_RSP')).once(); + }); + }); +}); + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/services/device-map.service.ts b/packages/adapter-tcp/src/services/device-map.service.ts new file mode 100644 index 00000000..ebc2de9e --- /dev/null +++ b/packages/adapter-tcp/src/services/device-map.service.ts @@ -0,0 +1,57 @@ +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { ConnectionWithDevice, Device, DeviceMap, Zone } from '@agnoc/domain'; + +export class DeviceMapService { + async setMapZones(connection: PacketConnection & ConnectionWithDevice, zones: Zone[]): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_SET_AREA_CLEAN_INFO_REQ', { + mapHeadId: connection.device.map.id.value, + planId: 0, + cleanAreaLength: zones.length, + cleanAreaList: zones.map((zone) => { + return { + cleanAreaId: zone.id.value, + type: 0, + coordinateLength: zone.coordinates.length, + coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), + }; + }), + }); + + response.assertPayloadName('DEVICE_MAPID_SET_AREA_CLEAN_INFO_RSP'); + } + + async enableWholeClean(connection: PacketConnection & ConnectionWithDevice): Promise { + const { id, restrictedZones, rooms } = connection.device.map; + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_SET_PLAN_PARAMS_REQ', { + mapHeadId: id.value, + // FIXME: this will change user map name. + mapName: 'Default', + planId: 2, + // FIXME: this will change user plan name. + planName: 'Default', + roomList: rooms.map((room) => ({ + roomId: room.id.value, + roomName: room.name, + enable: true, + })), + areaInfo: { + mapHeadId: id.value, + planId: 2, + cleanAreaLength: restrictedZones.length, + cleanAreaList: restrictedZones.map((zone) => ({ + cleanAreaId: zone.id.value, + type: 0, + coordinateLength: zone.coordinates.length, + coordinateList: zone.coordinates.map(({ x, y }) => ({ x, y })), + })), + }, + }); + + response.assertPayloadName('DEVICE_MAPID_SET_PLAN_PARAMS_RSP'); + } +} + +interface DeviceWithMap extends Device { + map: DeviceMap; +} diff --git a/packages/adapter-tcp/src/services/device-mode-changer.service.test.ts b/packages/adapter-tcp/src/services/device-mode-changer.service.test.ts new file mode 100644 index 00000000..098e64de --- /dev/null +++ b/packages/adapter-tcp/src/services/device-mode-changer.service.test.ts @@ -0,0 +1,329 @@ +import { DeviceCapability, DeviceMode, DeviceModeValue } from '@agnoc/domain'; +import { ArgumentInvalidException, DomainException } from '@agnoc/toolkit'; +import { anything, capture, deepEqual, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceModeChangerService } from './device-mode-changer.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { Device, DeviceSystem } from '@agnoc/domain'; +import type { WaiterService } from '@agnoc/toolkit'; + +describe('DeviceModeChangerService', function () { + let waiterService: WaiterService; + let service: DeviceModeChangerService; + let packetConnection: PacketConnection; + let device: Device; + let packetMessage: PacketMessage; + + beforeEach(function () { + waiterService = imock(); + service = new DeviceModeChangerService(instance(waiterService)); + packetConnection = imock(); + device = imock(); + packetMessage = imock(); + }); + + describe('#changeMode()', function () { + describe('to None', function () { + it('should do nothing when the mode is already none', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.None)); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.None)); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait(anything(), anything())).never(); + }); + + it('should change device mode to none', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.None)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.None)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify( + packetConnection.sendAndWait( + 'DEVICE_AUTO_CLEAN_REQ', + deepEqual({ + ctrlValue: 0, + cleanType: 2, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_AUTO_CLEAN_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should throw an error when device mode change times out', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenReject(new Error('Timeout')); + + await expect( + service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.None)), + ).to.be.rejectedWith(DomainException, `Unable to change device mode from 'Unknown' to 'None'`); + + verify(packetConnection.assertDevice()).once(); + verify( + packetConnection.sendAndWait( + 'DEVICE_AUTO_CLEAN_REQ', + deepEqual({ + ctrlValue: 0, + cleanType: 2, + }), + ), + ).once(); + verify(packetMessage.assertPayloadName('DEVICE_AUTO_CLEAN_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + }); + + describe('to Spot', function () { + it('should do nothing when the mode is already spot', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Spot)); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait(anything(), anything())).never(); + }); + + it('should change device mode to spot on device with map plans support', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Spot)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify(deviceSystem.supports(DeviceCapability.MAP_PLANS)).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x7aff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should change device mode to spot on device without map plans support', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(false); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Spot)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Spot)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify(deviceSystem.supports(DeviceCapability.MAP_PLANS)).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x2ff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should throw an error when device mode change times out', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenReject(new Error('Timeout')); + + await expect( + service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Spot)), + ).to.be.rejectedWith(DomainException, `Unable to change device mode from 'Unknown' to 'Spot'`); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x7aff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + }); + + describe('to Zone', function () { + it('should do nothing when the mode is already zone', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Zone)); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Zone)); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait(anything(), anything())).never(); + }); + + it('should change device mode to zone on device with map plans support', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Zone)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Zone)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify(deviceSystem.supports(DeviceCapability.MAP_PLANS)).once(); + verify(packetConnection.sendAndWait('DEVICE_AREA_CLEAN_REQ', deepEqual({ ctrlValue: 0 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_AREA_CLEAN_RSP')).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x79ff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should change device mode to zone on device without map plans support', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(false); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Zone)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Zone)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify(deviceSystem.supports(DeviceCapability.MAP_PLANS)).once(); + verify(packetConnection.sendAndWait('DEVICE_AREA_CLEAN_REQ', deepEqual({ ctrlValue: 0 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_AREA_CLEAN_RSP')).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x1ff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should throw an error when device mode change times out', async function () { + const deviceSystem: DeviceSystem = imock(); + + when(packetConnection.device).thenReturn(instance(device)); + when(device.system).thenReturn(instance(deviceSystem)); + when(deviceSystem.supports(anything())).thenReturn(true); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenReject(new Error('Timeout')); + + await expect( + service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Zone)), + ).to.be.rejectedWith(DomainException, `Unable to change device mode from 'Unknown' to 'Zone'`); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait('DEVICE_AREA_CLEAN_REQ', deepEqual({ ctrlValue: 0 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_AREA_CLEAN_RSP')).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', deepEqual({ mask: 0x79ff }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + }); + + describe('to Mop', function () { + it('should do nothing when the mode is already mop', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Mop)); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait(anything(), anything())).never(); + }); + + it('should change device mode to mop', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.None)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenResolve(); + + await service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Mop)); + + const [callback] = capture(waiterService.waitFor).first(); + + expect(callback()).to.be.false; + + when(device.mode).thenReturn(new DeviceMode(DeviceModeValue.Mop)); + + expect(callback()).to.be.true; + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_INTO_MODEIDLE_INFO_REQ', deepEqual({ mode: 7 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_INTO_MODEIDLE_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + + it('should throw an error when device mode change times out', async function () { + when(packetConnection.device).thenReturn(instance(device)); + when(packetConnection.sendAndWait(anything(), anything())).thenResolve(instance(packetMessage)); + when(waiterService.waitFor(anything(), anything())).thenReject(new Error('Timeout')); + + await expect( + service.changeMode(instance(packetConnection), new DeviceMode(DeviceModeValue.Mop)), + ).to.be.rejectedWith(DomainException, `Unable to change device mode from 'Unknown' to 'Mop'`); + + verify(packetConnection.assertDevice()).once(); + verify(packetConnection.sendAndWait('DEVICE_MAPID_INTO_MODEIDLE_INFO_REQ', deepEqual({ mode: 7 }))).once(); + verify(packetMessage.assertPayloadName('DEVICE_MAPID_INTO_MODEIDLE_INFO_RSP')).once(); + verify(waiterService.waitFor(anything(), deepEqual({ timeout: 5000 }))).once(); + }); + }); + + it('should thrown an error when the mode is not supported', async function () { + const deviceMode: DeviceMode = imock(); + + when(deviceMode.equals(anything())).thenReturn(false); + when(deviceMode.value).thenReturn('Unknown' as DeviceModeValue); + + await expect(service.changeMode(instance(packetConnection), instance(deviceMode))).to.be.rejectedWith( + ArgumentInvalidException, + `Value 'Unknown' is not supported in DeviceModeChangerService`, + ); + }); + }); +}); diff --git a/packages/adapter-tcp/src/services/device-mode-changer.service.ts b/packages/adapter-tcp/src/services/device-mode-changer.service.ts new file mode 100644 index 00000000..c3423a58 --- /dev/null +++ b/packages/adapter-tcp/src/services/device-mode-changer.service.ts @@ -0,0 +1,109 @@ +import { DeviceCapability, DeviceModeValue } from '@agnoc/domain'; +import { ArgumentInvalidException, DomainException } from '@agnoc/toolkit'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketMessage } from '../objects/packet.message'; +import type { DeviceMode, ConnectionWithDevice } from '@agnoc/domain'; +import type { WaiterService } from '@agnoc/toolkit'; + +const MODE_CHANGE_TIMEOUT = 5000; + +export enum ModeCtrlValue { + Stop = 0, + Start = 1, + Pause = 2, +} + +export class DeviceModeChangerService { + constructor(private readonly waiterService: WaiterService) {} + + async changeMode(connection: PacketConnection, mode: DeviceMode): Promise { + connection.assertDevice(); + + if (mode.equals(connection.device.mode)) { + return; + } + + if (mode.value === DeviceModeValue.None) { + await this.changeModeToNone(connection); + } else if (mode.value === DeviceModeValue.Spot) { + await this.changeModeToSpot(connection); + } else if (mode.value === DeviceModeValue.Zone) { + await this.changeModeToZone(connection); + } else if (mode.value === DeviceModeValue.Mop) { + await this.changeModeToMop(connection); + } else { + throw new ArgumentInvalidException( + `Value '${mode.value as string}' is not supported in ${this.constructor.name}`, + ); + } + + return this.waitForModeChange(connection, mode); + } + + private async changeModeToNone(connection: PacketConnection): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_AUTO_CLEAN_REQ', { + ctrlValue: ModeCtrlValue.Stop, + cleanType: 2, + }); + + response.assertPayloadName('DEVICE_AUTO_CLEAN_RSP'); + } + + private async changeModeToSpot(connection: PacketConnection & ConnectionWithDevice): Promise { + let mask = 0x78ff | 0x200; + + if (!connection.device.system.supports(DeviceCapability.MAP_PLANS)) { + mask = 0xff | 0x200; + } + + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', { mask }); + + response.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'); + } + + private async changeModeToZone(connection: PacketConnection & ConnectionWithDevice): Promise { + let response: PacketMessage; + + response = await connection.sendAndWait('DEVICE_AREA_CLEAN_REQ', { + ctrlValue: ModeCtrlValue.Stop, + }); + + response.assertPayloadName('DEVICE_AREA_CLEAN_RSP'); + + let mask = 0x78ff | 0x100; + + if (!connection.device.system.supports(DeviceCapability.MAP_PLANS)) { + mask = 0xff | 0x100; + } + + response = await connection.sendAndWait('DEVICE_MAPID_GET_GLOBAL_INFO_REQ', { mask }); + + response.assertPayloadName('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'); + } + + private async changeModeToMop(connection: PacketConnection & ConnectionWithDevice): Promise { + const response: PacketMessage = await connection.sendAndWait('DEVICE_MAPID_INTO_MODEIDLE_INFO_REQ', { + mode: 7, + }); + + response.assertPayloadName('DEVICE_MAPID_INTO_MODEIDLE_INFO_RSP'); + } + + private async waitForModeChange( + connection: PacketConnection & ConnectionWithDevice, + mode: DeviceMode, + ): Promise { + const callback = () => mode.equals(connection.device.mode); + const options = { timeout: MODE_CHANGE_TIMEOUT }; + + try { + await this.waiterService.waitFor(callback, options); + } catch (err) { + throw new DomainException( + `Unable to change device mode from '${connection.device.mode?.value ?? 'Unknown'}' to '${mode.value}'`, + undefined, + { cause: err }, + ); + } + } +} diff --git a/packages/adapter-tcp/src/services/packet-connection-finder.service.test.ts b/packages/adapter-tcp/src/services/packet-connection-finder.service.test.ts new file mode 100644 index 00000000..f636d3d4 --- /dev/null +++ b/packages/adapter-tcp/src/services/packet-connection-finder.service.test.ts @@ -0,0 +1,52 @@ +import { DomainException, ID } from '@agnoc/toolkit'; +import { imock, instance, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { PacketConnectionFinderService } from './packet-connection-finder.service'; +import type { ConnectionRepository, ConnectionWithDevice } from '@agnoc/domain'; + +describe('PacketConnectionFinderService', function () { + let connectionRepository: ConnectionRepository; + let service: PacketConnectionFinderService; + let connection: ConnectionWithDevice; + + beforeEach(function () { + connectionRepository = imock(); + service = new PacketConnectionFinderService(instance(connectionRepository)); + connection = imock(); + }); + + describe('#findByDeviceId()', function () { + it('should find a connection by device id when it is a packet connection', async function () { + const deviceId = new ID(1); + + when(connectionRepository.findByDeviceId(deviceId)).thenResolve([instance(connection)]); + when(connection.connectionType).thenReturn('PACKET'); + + const ret = await service.findByDeviceId(deviceId); + + expect(ret).to.equal(instance(connection)); + }); + + it('should not find anything when is it not a packet connection', async function () { + const deviceId = new ID(1); + + when(connectionRepository.findByDeviceId(deviceId)).thenResolve([instance(connection)]); + when(connection.connectionType).thenReturn('OTHER'); + + const ret = await service.findByDeviceId(deviceId); + + expect(ret).to.equal(undefined); + }); + + it('should throw an error when no connection is found', async function () { + const deviceId = new ID(1); + + when(connectionRepository.findByDeviceId(deviceId)).thenResolve([]); + + await expect(service.findByDeviceId(deviceId)).to.be.rejectedWith( + DomainException, + 'Unable to find a connection for the device with id 1', + ); + }); + }); +}); diff --git a/packages/adapter-tcp/src/services/packet-connection-finder.service.ts b/packages/adapter-tcp/src/services/packet-connection-finder.service.ts new file mode 100644 index 00000000..d2166333 --- /dev/null +++ b/packages/adapter-tcp/src/services/packet-connection-finder.service.ts @@ -0,0 +1,20 @@ +import { DomainException } from '@agnoc/toolkit'; +import { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { ConnectionRepository, Connection, ConnectionWithDevice } from '@agnoc/domain'; +import type { ID } from '@agnoc/toolkit'; + +export class PacketConnectionFinderService { + constructor(private readonly connectionRepository: ConnectionRepository) {} + + async findByDeviceId(deviceId: ID): Promise<(PacketConnection & ConnectionWithDevice) | undefined> { + const connections = await this.connectionRepository.findByDeviceId(deviceId); + + if (connections.length === 0) { + throw new DomainException(`Unable to find a connection for the device with id ${deviceId.value}`); + } + + return connections.find((connection: Connection): connection is PacketConnection & ConnectionWithDevice => + PacketConnection.isPacketConnection(connection), + ); + } +} diff --git a/packages/adapter-tcp/src/services/packet-event-publisher.service.test.ts b/packages/adapter-tcp/src/services/packet-event-publisher.service.test.ts new file mode 100644 index 00000000..00aa4f6f --- /dev/null +++ b/packages/adapter-tcp/src/services/packet-event-publisher.service.test.ts @@ -0,0 +1,47 @@ +import { Packet } from '@agnoc/transport-tcp'; +import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; +import { imock, instance, anything, verify, when } from '@johanblumenberg/ts-mockito'; +import { PacketMessage } from '../objects/packet.message'; +import { PacketEventPublisherService } from './packet-event-publisher.service'; +import type { PacketConnection } from '../aggregate-roots/packet-connection.aggregate-root'; +import type { PacketEventBus } from '../event-buses/packet.event-bus'; + +describe('PacketEventPublisherService', function () { + let packetEventBus: PacketEventBus; + let packetConnection: PacketConnection; + let service: PacketEventPublisherService; + + beforeEach(function () { + packetEventBus = imock(); + packetConnection = imock(); + service = new PacketEventPublisherService(instance(packetEventBus)); + }); + + describe('#publishPacketMessage()', function () { + it('should emit packets through the packet event bus when has handlers for the packet', async function () { + const packet = new Packet(givenSomePacketProps()); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + when(packetEventBus.listenerCount(anything())).thenReturn(1); + + await service.publishPacketMessage(packetMessage); + + verify(packetEventBus.listenerCount(packet.payload.opcode.name)).once(); + verify(packetEventBus.emit(packet.sequence.toString(), packetMessage)).once(); + verify(packetEventBus.emit(packet.payload.opcode.name, packetMessage)).once(); + }); + + it('should do nothing when there is no handlers for the packet', async function () { + const packet = new Packet(givenSomePacketProps()); + const packetMessage = new PacketMessage(instance(packetConnection), packet); + + when(packetEventBus.listenerCount(anything())).thenReturn(0); + + await service.publishPacketMessage(packetMessage); + + verify(packetEventBus.listenerCount(packet.payload.opcode.name)).once(); + verify(packetEventBus.emit(packet.sequence.toString(), packetMessage)).once(); + verify(packetEventBus.emit(packet.payload.opcode.name, packetMessage)).never(); + }); + }); +}); diff --git a/packages/adapter-tcp/src/services/packet-event-publisher.service.ts b/packages/adapter-tcp/src/services/packet-event-publisher.service.ts new file mode 100644 index 00000000..b721685e --- /dev/null +++ b/packages/adapter-tcp/src/services/packet-event-publisher.service.ts @@ -0,0 +1,26 @@ +import { debug } from '@agnoc/toolkit'; +import type { PacketEventBus, PacketEventBusEvents } from '../event-buses/packet.event-bus'; +import type { PacketMessage } from '../objects/packet.message'; +import type { PayloadDataName } from '@agnoc/transport-tcp'; + +export class PacketEventPublisherService { + private readonly debug = debug(__filename); + + constructor(private readonly packetEventBus: PacketEventBus) {} + + async publishPacketMessage(packetMessage: PacketMessage): Promise { + const name = packetMessage.packet.payload.opcode.name as PayloadDataName; + const sequence = packetMessage.packet.sequence.toString(); + + // Emit the packet event by the sequence string. + // This is used to wait for a response from a packet. + await this.packetEventBus.emit(sequence, packetMessage as PacketEventBusEvents[PayloadDataName]); + + // Emit the packet event by the opcode name. + if (this.packetEventBus.listenerCount(name) !== 0) { + await this.packetEventBus.emit(name, packetMessage as PacketEventBusEvents[PayloadDataName]); + } else { + this.debug(`unhandled packet event '${name}': ${packetMessage.packet.toString()}`); + } + } +} diff --git a/packages/adapter-tcp/src/tcp.server.test.ts b/packages/adapter-tcp/src/tcp.server.test.ts new file mode 100644 index 00000000..1b230d81 --- /dev/null +++ b/packages/adapter-tcp/src/tcp.server.test.ts @@ -0,0 +1,40 @@ +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { TCPServer } from './tcp.server'; +import type { CommandsOrQueries, ConnectionRepository, DeviceRepository } from '@agnoc/domain'; +import type { EventHandlerRegistry, TaskHandlerRegistry } from '@agnoc/toolkit'; + +describe('TCPServer', function () { + let domainEventHandlerRegistry: EventHandlerRegistry; + let commandHandlerRegistry: TaskHandlerRegistry; + let deviceRepository: DeviceRepository; + let connectionRepository: ConnectionRepository; + let tcpAdapter: TCPServer; + + beforeEach(function () { + domainEventHandlerRegistry = imock(); + commandHandlerRegistry = imock(); + deviceRepository = imock(); + connectionRepository = imock(); + tcpAdapter = new TCPServer( + instance(deviceRepository), + instance(connectionRepository), + instance(domainEventHandlerRegistry), + instance(commandHandlerRegistry), + ); + }); + + it('should listen and close servers', async function () { + await tcpAdapter.listen(); + await tcpAdapter.close(); + }); + + it('should listen and close servers with custom ports', async function () { + await tcpAdapter.listen({ ports: { cmd: 0, map: 0, ntp: 0 } }); + await tcpAdapter.close(); + }); + + it('should listen and close servers with custom host', async function () { + await tcpAdapter.listen({ host: '127.0.0.1' }); + await tcpAdapter.close(); + }); +}); diff --git a/packages/adapter-tcp/src/tcp.server.ts b/packages/adapter-tcp/src/tcp.server.ts new file mode 100644 index 00000000..f48352e1 --- /dev/null +++ b/packages/adapter-tcp/src/tcp.server.ts @@ -0,0 +1,260 @@ +import { EventHandlerRegistry, WaiterService } from '@agnoc/toolkit'; +import { + getCustomDecoders, + getProtobufRoot, + PacketMapper, + PacketServer, + PayloadMapper, + PayloadDataParserService, + PacketFactory, +} from '@agnoc/transport-tcp'; +import { CleanSpotCommandHandler } from './command-handlers/clean-spot.command-handler'; +import { CleanZonesCommandHandler } from './command-handlers/clean-zones.command-handler'; +import { LocateDeviceCommandHandler } from './command-handlers/locate-device.command-handler'; +import { PauseCleaningCommandHandler } from './command-handlers/pause-cleaning.command-handler'; +import { ResetConsumableCommandHandler } from './command-handlers/reset-consumable.command-handler'; +import { ReturnHomeCommandHandler } from './command-handlers/return-home.command-handler'; +import { SetCarpetModeCommandHandler } from './command-handlers/set-carpet-mode.command-handler'; +import { SetDeviceModeCommandHandler } from './command-handlers/set-device-mode.command-handler'; +import { SetDeviceQuietHoursCommandHandler } from './command-handlers/set-device-quiet-hours.command-handler'; +import { SetDeviceVoiceCommandHandler } from './command-handlers/set-device-voice.command-handler'; +import { SetFanSpeedCommandHandler } from './command-handlers/set-fan-speed.command-handler'; +import { SetWaterLevelCommandHandler } from './command-handlers/set-water-level.command-handler'; +import { StartCleaningCommandHandler } from './command-handlers/start-cleaning.command-handler'; +import { StopCleaningCommandHandler } from './command-handlers/stop-cleaning.command-handler'; +import { NTPServerConnectionHandler } from './connection-handlers/ntp-server.connection-handler'; +import { PackerServerConnectionHandler } from './connection-handlers/packet-server.connection-handler'; +import { LockDeviceWhenDeviceIsConnectedEventHandler } from './domain-event-handlers/lock-device-when-device-is-connected-event-handler.event-handler'; +import { QueryDeviceInfoWhenDeviceIsLockedEventHandler } from './domain-event-handlers/query-device-info-when-device-is-locked-event-handler.event-handler'; +import { SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler } from './domain-event-handlers/set-device-connected-when-connection-device-changed.event-handler'; +import { PacketEventBus } from './event-buses/packet.event-bus'; +import { PacketConnectionFactory } from './factories/connection.factory'; +import { CleanModeMapper } from './mappers/clean-mode.mapper'; +import { DeviceBatteryMapper } from './mappers/device-battery.mapper'; +import { DeviceErrorMapper } from './mappers/device-error.mapper'; +import { DeviceFanSpeedMapper } from './mappers/device-fan-speed.mapper'; +import { DeviceModeMapper } from './mappers/device-mode.mapper'; +import { DeviceOrderMapper } from './mappers/device-order.mapper'; +import { DeviceStateMapper } from './mappers/device-state.mapper'; +import { DeviceWaterLevelMapper } from './mappers/device-water-level.mapper'; +import { VoiceSettingMapper } from './mappers/voice-setting.mapper'; +import { WeekDayListMapper } from './mappers/week-day-list.mapper'; +import { ClientHeartbeatEventHandler } from './packet-event-handlers/client-heartbeat.event-handler'; +import { ClientLoginEventHandler } from './packet-event-handlers/client-login.event-handler'; +import { DeviceBatteryUpdateEventHandler } from './packet-event-handlers/device-battery-update.event-handler'; +import { DeviceCleanMapDataReportEventHandler } from './packet-event-handlers/device-clean-map-data-report.event-handler'; +import { DeviceCleanMapReportEventHandler } from './packet-event-handlers/device-clean-map-report.event-handler'; +import { DeviceCleanTaskReportEventHandler } from './packet-event-handlers/device-clean-task-report.event-handler'; +import { DeviceGetAllGlobalMapEventHandler } from './packet-event-handlers/device-get-all-global-map.event-handler'; +import { DeviceLocatedEventHandler } from './packet-event-handlers/device-located.event-handler'; +import { DeviceLockedEventHandler } from './packet-event-handlers/device-locked.event-handler'; +import { DeviceMapChargerPositionUpdateEventHandler } from './packet-event-handlers/device-map-charger-position-update.event-handler'; +import { DeviceMapUpdateEventHandler } from './packet-event-handlers/device-map-update.event-handler'; +import { DeviceMapWorkStatusUpdateEventHandler } from './packet-event-handlers/device-map-work-status-update.event-handler'; +import { DeviceMemoryMapInfoEventHandler } from './packet-event-handlers/device-memory-map-info.event-handler'; +import { DeviceNetworkUpdateEventHandler } from './packet-event-handlers/device-network-update.event-handler'; +import { DeviceOfflineEventHandler } from './packet-event-handlers/device-offline.event-handler'; +import { DeviceOrderListUpdateEventHandler } from './packet-event-handlers/device-order-list-update.event-handler'; +import { DeviceRegisterEventHandler } from './packet-event-handlers/device-register.event-handler'; +import { DeviceSettingsUpdateEventHandler } from './packet-event-handlers/device-settings-update.event-handler'; +import { DeviceTimeUpdateEventHandler } from './packet-event-handlers/device-time-update.event-handler'; +import { DeviceUpgradeInfoEventHandler } from './packet-event-handlers/device-upgrade-info.event-handler'; +import { DeviceVersionUpdateEventHandler } from './packet-event-handlers/device-version-update.event-handler'; +import { GetDeviceConsumablesQueryHandler } from './query-handlers/get-device-consumables.query-handler'; +import { ConnectionDeviceUpdaterService } from './services/connection-device-updater.service'; +import { DeviceCleaningService } from './services/device-cleaning.service'; +import { DeviceMapService } from './services/device-map.service'; +import { DeviceModeChangerService } from './services/device-mode-changer.service'; +import { PacketConnectionFinderService } from './services/packet-connection-finder.service'; +import { PacketEventPublisherService } from './services/packet-event-publisher.service'; +import type { CommandsOrQueries, ConnectionRepository, DeviceRepository } from '@agnoc/domain'; +import type { Server, TaskHandlerRegistry } from '@agnoc/toolkit'; +import type { AddressInfo } from 'net'; + +export class TCPServer implements Server { + private readonly cmdServer: PacketServer; + private readonly mapServer: PacketServer; + private readonly ntpServer: PacketServer; + + constructor( + private readonly deviceRepository: DeviceRepository, + private readonly connectionRepository: ConnectionRepository, + private readonly domainEventHandlerRegistry: EventHandlerRegistry, + private readonly commandQueryHandlerRegistry: TaskHandlerRegistry, + ) { + // Packet foundation + const payloadMapper = new PayloadMapper(new PayloadDataParserService(getProtobufRoot(), getCustomDecoders())); + const packetMapper = new PacketMapper(payloadMapper); + const packetFactory = new PacketFactory(); + + // Servers + this.ntpServer = new PacketServer(packetMapper); + this.cmdServer = new PacketServer(packetMapper); + this.mapServer = new PacketServer(packetMapper); + + // Mappers + const deviceFanSpeedMapper = new DeviceFanSpeedMapper(); + const deviceWaterLevelMapper = new DeviceWaterLevelMapper(); + const voiceSettingMapper = new VoiceSettingMapper(); + const deviceStateMapper = new DeviceStateMapper(); + const deviceModeMapper = new DeviceModeMapper(); + const deviceErrorMapper = new DeviceErrorMapper(); + const deviceBatteryMapper = new DeviceBatteryMapper(); + const cleanModeMapper = new CleanModeMapper(); + const weekDayListMapper = new WeekDayListMapper(); + const deviceOrderMapper = new DeviceOrderMapper( + deviceFanSpeedMapper, + deviceWaterLevelMapper, + cleanModeMapper, + weekDayListMapper, + ); + + // Packet event bus + const packetEventBus = new PacketEventBus(); + const packetEventHandlerRegistry = new EventHandlerRegistry(packetEventBus); + + // Services + const waiterService = new WaiterService(); + const connectionDeviceUpdaterService = new ConnectionDeviceUpdaterService( + this.connectionRepository, + deviceRepository, + ); + const packetEventPublisherService = new PacketEventPublisherService(packetEventBus); + const packetConnectionFinderService = new PacketConnectionFinderService(this.connectionRepository); + const deviceModeChangerService = new DeviceModeChangerService(waiterService); + const deviceCleaningService = new DeviceCleaningService(); + const deviceMapService = new DeviceMapService(); + + // Connection + const packetConnectionFactory = new PacketConnectionFactory(packetEventBus, packetFactory); + const connectionManager = new PackerServerConnectionHandler( + this.connectionRepository, + packetConnectionFactory, + connectionDeviceUpdaterService, + packetEventPublisherService, + ); + + connectionManager.register(this.cmdServer, this.mapServer); + + // Time Sync server controller + const ntpServerConnectionHandler = new NTPServerConnectionHandler(packetFactory); + + ntpServerConnectionHandler.register(this.ntpServer); + + // Packet event handlers + packetEventHandlerRegistry.register( + new ClientHeartbeatEventHandler(), + new ClientLoginEventHandler(), + new DeviceBatteryUpdateEventHandler(deviceBatteryMapper, this.deviceRepository), + new DeviceCleanMapDataReportEventHandler(), + new DeviceCleanMapReportEventHandler(), + new DeviceCleanTaskReportEventHandler(), + new DeviceGetAllGlobalMapEventHandler(), + new DeviceLocatedEventHandler(), + new DeviceLockedEventHandler(this.deviceRepository), + new DeviceMapChargerPositionUpdateEventHandler(this.deviceRepository), + new DeviceMapWorkStatusUpdateEventHandler( + deviceStateMapper, + deviceModeMapper, + deviceErrorMapper, + deviceBatteryMapper, + deviceFanSpeedMapper, + deviceWaterLevelMapper, + this.deviceRepository, + ), + new DeviceMemoryMapInfoEventHandler(), + new DeviceOfflineEventHandler(), + new DeviceOrderListUpdateEventHandler(deviceOrderMapper, this.deviceRepository), + new DeviceRegisterEventHandler(this.deviceRepository), + new DeviceSettingsUpdateEventHandler(voiceSettingMapper, this.deviceRepository), + new DeviceTimeUpdateEventHandler(), + new DeviceUpgradeInfoEventHandler(), + new DeviceVersionUpdateEventHandler(this.deviceRepository), + new DeviceNetworkUpdateEventHandler(this.deviceRepository), + new DeviceMapUpdateEventHandler( + deviceBatteryMapper, + deviceModeMapper, + deviceStateMapper, + deviceErrorMapper, + deviceFanSpeedMapper, + this.deviceRepository, + ), + ); + + // Domain event handlers + this.domainEventHandlerRegistry.register( + new LockDeviceWhenDeviceIsConnectedEventHandler(packetConnectionFinderService), + new QueryDeviceInfoWhenDeviceIsLockedEventHandler(packetConnectionFinderService), + new SetDeviceAsConnectedWhenConnectionDeviceAddedEventHandler(this.connectionRepository, this.deviceRepository), + ); + + // Command event handlers + this.commandQueryHandlerRegistry.register( + new CleanSpotCommandHandler(packetConnectionFinderService, deviceModeChangerService, deviceCleaningService), + new CleanZonesCommandHandler( + packetConnectionFinderService, + deviceModeChangerService, + deviceMapService, + deviceCleaningService, + ), + new GetDeviceConsumablesQueryHandler(packetConnectionFinderService, this.deviceRepository), + new LocateDeviceCommandHandler(packetConnectionFinderService), + new PauseCleaningCommandHandler(packetConnectionFinderService, deviceCleaningService), + new ResetConsumableCommandHandler(packetConnectionFinderService, this.deviceRepository), + new ReturnHomeCommandHandler(packetConnectionFinderService), + new SetCarpetModeCommandHandler(packetConnectionFinderService, this.deviceRepository), + new SetDeviceModeCommandHandler(packetConnectionFinderService, deviceModeChangerService), + new SetDeviceQuietHoursCommandHandler(packetConnectionFinderService), + new SetDeviceVoiceCommandHandler(packetConnectionFinderService, voiceSettingMapper, this.deviceRepository), + new SetFanSpeedCommandHandler(packetConnectionFinderService, deviceFanSpeedMapper, this.deviceRepository), + new SetWaterLevelCommandHandler(packetConnectionFinderService, deviceWaterLevelMapper, this.deviceRepository), + new StartCleaningCommandHandler( + packetConnectionFinderService, + deviceModeChangerService, + deviceCleaningService, + deviceMapService, + ), + new StopCleaningCommandHandler(packetConnectionFinderService, deviceCleaningService), + ); + } + + async listen(options: TCPAdapterListenOptions = listenDefaultOptions): Promise { + const host = options.host; + const ports = options.ports ?? listenDefaultOptions.ports; + + await Promise.all([ + this.cmdServer.listen({ host, port: ports.cmd }), + this.mapServer.listen({ host, port: ports.map }), + this.ntpServer.listen({ host, port: ports.ntp }), + ]); + + return { + ports: { + cmd: (this.cmdServer.address as AddressInfo).port, + map: (this.mapServer.address as AddressInfo).port, + ntp: (this.ntpServer.address as AddressInfo).port, + }, + }; + } + + async close(): Promise { + await Promise.all([this.cmdServer.close(), this.mapServer.close(), this.ntpServer.close()]); + } +} + +const listenDefaultOptions = { ports: { cmd: 4010, map: 4030, ntp: 4050 } } satisfies TCPAdapterListenOptions; + +export interface TCPAdapterListenOptions { + host?: string; + ports?: ServerPorts; +} + +interface TCPAdapterListenReturn { + ports: ServerPorts; +} + +export interface ServerPorts { + cmd: number; + map: number; + ntp: number; +} diff --git a/packages/adapter-tcp/src/value-objects/message.value-object.ts b/packages/adapter-tcp/src/value-objects/message.value-object.ts deleted file mode 100644 index 96387d0c..00000000 --- a/packages/adapter-tcp/src/value-objects/message.value-object.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { ValueObject, isPresent, ArgumentNotProvidedException, ArgumentInvalidException } from '@agnoc/toolkit'; -import { Packet } from '@agnoc/transport-tcp'; -import { Connection } from '../emitters/connection.emitter'; -import type { PayloadObjectName, PayloadObjectFrom } from '@agnoc/transport-tcp'; - -export interface MessageProps { - connection: Connection; - packet: Packet; -} - -export type MessageHandler = (message: Message) => void; -export type MessageHandlers = Partial<{ - [Name in PayloadObjectName]: MessageHandler; -}>; - -export class Message extends ValueObject> { - constructor(props: MessageProps) { - super(props); - } - - get connection(): Connection { - return this.props.connection; - } - - get packet(): Packet { - return this.props.packet; - } - - get opname(): Name { - return this.packet.payload.opcode.name as Name; - } - - respond(opname: RName, object: PayloadObjectFrom): boolean { - return this.connection.respond({ packet: this.packet, opname, object }); - } - - public override toString(): string { - return '[Message]'; - } - - protected validate(props: MessageProps): void { - if (![props.connection, props.packet].every(isPresent)) { - throw new ArgumentNotProvidedException('Missing property in message constructor'); - } - - if (!(props.connection instanceof Connection)) { - throw new ArgumentInvalidException('Invalid connection in message constructor'); - } - - if (!(props.packet instanceof Packet)) { - throw new ArgumentInvalidException('Invalid packet in message constructor'); - } - - if (!isPresent(props.packet.payload.opcode.name)) { - throw new ArgumentInvalidException('Unknown packet in message constructor'); - } - } -} diff --git a/packages/adapter-tcp/test/integration/tcp.server.test.ts b/packages/adapter-tcp/test/integration/tcp.server.test.ts new file mode 100644 index 00000000..c5f3d057 --- /dev/null +++ b/packages/adapter-tcp/test/integration/tcp.server.test.ts @@ -0,0 +1,230 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { once } from 'events'; +import { CommandQueryBus, ConnectionRepository, Device, DeviceRepository, DomainEventBus } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { EventHandlerRegistry, ID, MemoryAdapter, TaskHandlerRegistry } from '@agnoc/toolkit'; +import { + getCustomDecoders, + getProtobufRoot, + PacketFactory, + PacketMapper, + PacketSocket, + PayloadMapper, + PayloadDataParserService, +} from '@agnoc/transport-tcp'; +import { expect } from 'chai'; +import { TCPServer } from '@agnoc/adapter-tcp'; +import type { TCPAdapterListenOptions } from '@agnoc/adapter-tcp'; +import type { CommandsOrQueries } from '@agnoc/domain'; +import type { ICLIENT_ONLINE_REQ, IDEVICE_REGISTER_REQ } from '@agnoc/schemas-tcp'; +import type { CreatePacketProps, Packet } from '@agnoc/transport-tcp'; + +describe('Integration', function () { + let domainEventBus: DomainEventBus; + let commandBus: CommandQueryBus; + let domainEventHandlerRegistry: EventHandlerRegistry; + let commandHandlerRegistry: TaskHandlerRegistry; + let deviceRepository: DeviceRepository; + let connectionRepository: ConnectionRepository; + let tcpAdapter: TCPServer; + let packetSocket: PacketSocket; + let secondPacketSocket: PacketSocket; + let packetFactory: PacketFactory; + + beforeEach(function () { + // Server blocks + domainEventBus = new DomainEventBus(); + commandBus = new CommandQueryBus(); + + domainEventHandlerRegistry = new EventHandlerRegistry(domainEventBus); + commandHandlerRegistry = new TaskHandlerRegistry(commandBus); + deviceRepository = new DeviceRepository(domainEventBus, new MemoryAdapter()); + connectionRepository = new ConnectionRepository(domainEventBus, new MemoryAdapter()); + tcpAdapter = new TCPServer( + deviceRepository, + connectionRepository, + domainEventHandlerRegistry, + commandHandlerRegistry, + ); + + // Client blocks + const payloadMapper = new PayloadMapper(new PayloadDataParserService(getProtobufRoot(), getCustomDecoders())); + const packetMapper = new PacketMapper(payloadMapper); + + packetSocket = new PacketSocket(packetMapper); + secondPacketSocket = new PacketSocket(packetMapper); + packetFactory = new PacketFactory(); + }); + + afterEach(async function () { + await tcpAdapter.close(); + }); + + describe('packet validation', function () { + it('should handle heartbeat packets on cmd server', async function () { + const createPacketProps = givenSomeCreatePacketProps(); + const sentPacket = packetFactory.create('CLIENT_HEARTBEAT_REQ', {}, createPacketProps); + const { ports } = await tcpAdapter.listen(givenSomeServerPorts()); + + await packetSocket.connect(ports.cmd); + + void packetSocket.write(sentPacket); + + const [receivedPacket] = (await once(packetSocket, 'data')) as Packet[]; + + expect(receivedPacket).to.exist; + expect(receivedPacket.ctype).to.be.equal(sentPacket.ctype); + expect(receivedPacket.sequence.equals(sentPacket.sequence)).to.be.true; + expect(receivedPacket.flow).to.be.equal(1); + expect(receivedPacket.deviceId.equals(sentPacket.deviceId)).to.be.true; + expect(receivedPacket.userId.equals(sentPacket.userId)).to.be.true; + expect(receivedPacket.payload.opcode.value).to.be.equal('CLIENT_HEARTBEAT_RSP'); + expect(receivedPacket.payload.data).to.be.deep.equal({}); + }); + + it('should handle heartbeat packets on map server', async function () { + const createPacketProps = givenSomeCreatePacketProps(); + const sentPacket = packetFactory.create('CLIENT_HEARTBEAT_REQ', {}, createPacketProps); + const { ports } = await tcpAdapter.listen(givenSomeServerPorts()); + + await packetSocket.connect(ports.map); + + void packetSocket.write(sentPacket); + + const [receivedPacket] = (await once(packetSocket, 'data')) as Packet[]; + + expect(receivedPacket).to.exist; + expect(receivedPacket.ctype).to.be.equal(sentPacket.ctype); + expect(receivedPacket.sequence.equals(sentPacket.sequence)).to.be.true; + expect(receivedPacket.flow).to.be.equal(1); + expect(receivedPacket.deviceId.equals(sentPacket.deviceId)).to.be.true; + expect(receivedPacket.userId.equals(sentPacket.userId)).to.be.true; + expect(receivedPacket.payload.opcode.value).to.be.equal('CLIENT_HEARTBEAT_RSP'); + expect(receivedPacket.payload.data).to.be.deep.equal({}); + }); + + it('should handle ntp connections', async function () { + const { ports } = await tcpAdapter.listen(givenSomeServerPorts()); + const now = Math.floor(Date.now() / 1000); + + await packetSocket.connect(ports.ntp); + + const [receivedPacket] = (await once(packetSocket, 'data')) as Packet<'DEVICE_TIME_SYNC_RSP'>[]; + + expect(receivedPacket).to.exist; + expect(receivedPacket.ctype).to.be.equal(2); + expect(receivedPacket.flow).to.be.equal(0); + expect(receivedPacket.deviceId.value).to.be.equal(0); + expect(receivedPacket.userId.value).to.be.equal(0); + expect(receivedPacket.payload.opcode.value).to.be.equal('DEVICE_TIME_SYNC_RSP'); + expect(receivedPacket.payload.data.result).to.be.equal(0); + expect(receivedPacket.payload.data.body.time).to.be.greaterThanOrEqual(now); + }); + }); + + describe('flow validation', function () { + it('should handle a device registration', async function () { + const { ports } = await tcpAdapter.listen(givenSomeServerPorts()); + + await packetSocket.connect(ports.cmd); + + void packetSocket.write( + packetFactory.create('DEVICE_REGISTER_REQ', givenADeviceRegisterRequest(), givenSomeCreatePacketProps()), + ); + + const [receivedPacket] = (await once(packetSocket, 'data')) as Packet<'DEVICE_REGISTER_RSP'>[]; + + expect(receivedPacket.payload.opcode.value).to.be.equal('DEVICE_REGISTER_RSP'); + + const device = await deviceRepository.findOneById(new ID(receivedPacket.payload.data.device.id)); + + expect(device).to.exist; + }); + + it('should handle a device connection', async function () { + const device = new Device(givenSomeDeviceProps()); + let receivedPacket: Packet; + let secondReceivedPacket: Packet; + + await deviceRepository.saveOne(device); + + const { ports } = await tcpAdapter.listen(givenSomeServerPorts()); + + await packetSocket.connect(ports.cmd); + + void packetSocket.write( + packetFactory.create('CLIENT_ONLINE_REQ', givenAClientOnlineRequest(), givenSomeCreatePacketProps(device)), + ); + + [receivedPacket] = (await once(packetSocket, 'data')) as Packet<'CLIENT_ONLINE_RSP'>[]; + + expect(receivedPacket.payload.opcode.value).to.be.equal('CLIENT_ONLINE_RSP'); + + await secondPacketSocket.connect(ports.map); + + void secondPacketSocket.write( + packetFactory.create('CLIENT_HEARTBEAT_REQ', givenAClientOnlineRequest(), givenSomeCreatePacketProps(device)), + ); + + // The device already has two identified connections and + // the device should be marked as connected. + // eslint-disable-next-line prefer-const + [, [receivedPacket], [secondReceivedPacket]] = await Promise.all([ + domainEventBus.once('DeviceConnectedDomainEvent'), + once(packetSocket, 'data') as Promise[]>, + once(secondPacketSocket, 'data') as Promise[]>, + ]); + + expect(device.isConnected).to.be.true; + expect(receivedPacket.payload.opcode.value).to.be.equal('DEVICE_CONTROL_LOCK_REQ'); + expect(secondReceivedPacket.payload.opcode.value).to.be.equal('CLIENT_HEARTBEAT_RSP'); + + void packetSocket.write( + packetFactory.create('DEVICE_CONTROL_LOCK_RSP', { result: 0 }, givenSomeCreatePacketProps(device)), + ); + + // The device should be marked as locked. + await domainEventBus.once('DeviceLockedDomainEvent'); + + expect(device.isLocked).to.be.true; + }); + }); +}); + +function givenSomeServerPorts(): TCPAdapterListenOptions { + return { + ports: { + cmd: 0, + map: 0, + ntp: 0, + }, + }; +} + +function givenADeviceRegisterRequest(): IDEVICE_REGISTER_REQ { + return { + softwareVersion: 'S2.1.40.30.01R', + hardwareVersion: '1.0.1', + deviceSerialNumber: '1234567890', + deviceMac: '00:00:00:00:00:00', + deviceType: 9, + customerFirmwareId: 1003, + ctrlVersion: 'V4.0', + }; +} + +function givenAClientOnlineRequest(): ICLIENT_ONLINE_REQ { + return { + deviceSerialNumber: '1234567890', + unk1: false, + unk2: 0, + }; +} + +function givenSomeCreatePacketProps(device?: Device): CreatePacketProps { + // This properties should be inverted given that we are the client. + return { + deviceId: device?.userId ?? new ID(0), + userId: device?.id ?? new ID(0), + }; +} diff --git a/packages/cli/README.md b/packages/cli/README.md index ad3a9dd9..2d0deff5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,6 +24,16 @@ $ agnoc --help ## Commands +### REPL + +Start a simplified REPL to control devices using queries and commands. + +``` +$ agnoc repl +agnoc> { devices } = await server.trigger(new Queries.FindDevicesQuery()) +agnoc> await server.trigger(new Commands.LocateDeviceCommand({ deviceId: devices[0].id })) +``` + ### Wlan This command configures the wifi connection of the robot to the given one. diff --git a/packages/cli/package.json b/packages/cli/package.json index 13735699..a84c168d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -81,6 +81,9 @@ } }, "dependencies": { + "@agnoc/adapter-tcp": "^0.18.0-next.0", + "@agnoc/core": "^0.18.0-next.0", + "@agnoc/domain": "^0.18.0-next.0", "@agnoc/toolkit": "^0.18.0-next.0", "@agnoc/transport-tcp": "^0.18.0-next.0", "chalk": "^4.1.1", @@ -93,10 +96,12 @@ "pcap": "^3.1.0" }, "devDependencies": { + "@agnoc/test-support": "^0.18.0-next.0", "@johanblumenberg/ts-mockito": "^1.0.35", "chai": "^4.3.7", "execa": "^5.1.1", - "mock-fs": "^5.2.0" + "mock-fs": "^5.2.0", + "pcap": "^3.1.0" }, "engines": { "node": ">=18.12" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5cec9e8a..e67d01da 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,6 +1,11 @@ /* istanbul ignore file */ +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { start as startREPL } from 'node:repl'; +import { TCPServer } from '@agnoc/adapter-tcp'; +import { AgnocServer } from '@agnoc/core'; import { - PayloadObjectParserService, + PayloadDataParserService, getProtobufRoot, PacketMapper, getCustomDecoders, @@ -13,6 +18,7 @@ import wifi from 'node-wifi'; import { DecodeCommand } from './commands/decode.command'; import { EncodeCommand } from './commands/encode.command'; import { ReadCommand } from './commands/read.command'; +import { REPLCommand } from './commands/repl.command'; import { WlanConfigCommand } from './commands/wlan-config.command'; import { WlanCommand } from './commands/wlan.command'; import type { Stdio } from './interfaces/stdio'; @@ -33,13 +39,24 @@ export function main(): void { stderr: process.stderr, }; - const payloadMapper = new PayloadMapper(new PayloadObjectParserService(getProtobufRoot(), getCustomDecoders())); + const payloadMapper = new PayloadMapper(new PayloadDataParserService(getProtobufRoot(), getCustomDecoders())); const packetMapper = new PacketMapper(payloadMapper); const decodeCommand = new DecodeCommand(stdio, packetMapper); const readCommand = new ReadCommand(stdio, packetMapper); const encodeCommand = new EncodeCommand(stdio, packetMapper); const wlanConfigCommand = new WlanConfigCommand('192.168.5.1', 6008); const wlanCommand = new WlanCommand(cliUx, wifi, wlanConfigCommand); + const agnocServer = new AgnocServer(); + + agnocServer.buildAdapter( + (container) => + new TCPServer( + container.deviceRepository, + container.connectionRepository, + container.domainEventHandlerRegistry, + container.commandQueryHandlerRegistry, + ), + ); function handleError(e: Error): void { cliUx.action.stop(chalk.red('!')); @@ -113,6 +130,19 @@ export function main(): void { cliUx.action.stop(); }); + program + .command('repl') + .description('start a REPL with a server listening for connections') + .option('-t, --history ', 'file to store the REPL history.', path.join(tmpdir(), 'agnoc-repl-history')) + .action(({ history }: { history: string }) => { + const repl = startREPL({ prompt: 'agnoc> ' }); + const replCommand = new REPLCommand(agnocServer, repl); + + return replCommand.action({ + historyFilename: history, + }); + }); + program.addHelpCommand('help [command]', 'display help for command'); program.parse(process.argv); diff --git a/packages/cli/src/commands/decode.command.test.ts b/packages/cli/src/commands/decode.command.test.ts index 8829cd4e..cd11297e 100644 --- a/packages/cli/src/commands/decode.command.test.ts +++ b/packages/cli/src/commands/decode.command.test.ts @@ -1,10 +1,10 @@ import { PassThrough } from 'stream'; +import { readStream } from '@agnoc/test-support'; import { Packet } from '@agnoc/transport-tcp'; import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; import { imock, when, anything, verify, instance, deepEqual } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; import mockFS, { restore } from 'mock-fs'; -import { readStream } from '../test-support'; import { DecodeCommand } from './decode.command'; import type { Stdio } from '../interfaces/stdio'; import type { PacketMapper } from '@agnoc/transport-tcp'; @@ -47,7 +47,7 @@ describe('DecodeCommand', function () { const [ret] = await readStream(stdio.stdout, 'utf8'); expect(ret).to.equal( - '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","object":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', + '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","data":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', ); verify(packetMapper.toDomain(deepEqual(buffer))).once(); @@ -73,7 +73,7 @@ describe('DecodeCommand', function () { sequence: 'fb3dd1ebc0e6c58f', payload: { opcode: 'DEVICE_GETTIME_RSP', - object: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }, }, ]); @@ -91,7 +91,7 @@ describe('DecodeCommand', function () { const [ret] = await readStream(stdio.stdout, 'utf8'); expect(ret).to.equal( - '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","object":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', + '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","data":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', ); verify(packetMapper.toDomain(deepEqual(buffer))).once(); @@ -115,7 +115,7 @@ describe('DecodeCommand', function () { sequence: 'fb3dd1ebc0e6c58f', payload: { opcode: 'DEVICE_GETTIME_RSP', - object: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }, }, ]); diff --git a/packages/cli/src/commands/encode.command.test.ts b/packages/cli/src/commands/encode.command.test.ts index a39a5910..04985700 100644 --- a/packages/cli/src/commands/encode.command.test.ts +++ b/packages/cli/src/commands/encode.command.test.ts @@ -1,10 +1,11 @@ import { PassThrough } from 'stream'; +import { readStream } from '@agnoc/test-support'; import { ID } from '@agnoc/toolkit'; import { OPCode, Packet, PacketSequence, Payload } from '@agnoc/transport-tcp'; import { imock, when, anything, verify, instance, deepEqual } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; import mockFS, { restore } from 'mock-fs'; -import { givenAJSONPacket, readStream } from '../test-support'; +import { givenAJSONPacket } from '../test-support'; import { EncodeCommand } from './encode.command'; import type { Stdio } from '../interfaces/stdio'; import type { JSONPacket } from '../streams/packet-encode-transform.stream'; @@ -60,7 +61,7 @@ describe('EncodeCommand', function () { sequence: PacketSequence.fromString(jsonPacket.sequence), payload: new Payload({ opcode: OPCode.fromName(jsonPacket.payload.opcode), - object: jsonPacket.payload.object, + data: jsonPacket.payload.data, }), }), ), @@ -90,7 +91,7 @@ describe('EncodeCommand', function () { sequence: PacketSequence.fromString(jsonPacket.sequence), payload: new Payload({ opcode: OPCode.fromName(jsonPacket.payload.opcode), - object: jsonPacket.payload.object, + data: jsonPacket.payload.data, }), }), ), diff --git a/packages/cli/src/commands/read.command.test.ts b/packages/cli/src/commands/read.command.test.ts index 3e085a79..1561b781 100644 --- a/packages/cli/src/commands/read.command.test.ts +++ b/packages/cli/src/commands/read.command.test.ts @@ -1,10 +1,10 @@ import path from 'path'; import { PassThrough } from 'stream'; +import { readStream } from '@agnoc/test-support'; import { Packet } from '@agnoc/transport-tcp'; import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { ReadCommand } from './read.command'; import type { Stdio } from '../interfaces/stdio'; import type { PacketMapper } from '@agnoc/transport-tcp'; @@ -35,10 +35,10 @@ describe('ReadCommand', function () { const [ret1, ret2] = await readStream(stdio.stdout, 'utf8'); expect(ret1).to.equal( - '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","object":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', + '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","data":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', ); expect(ret2).to.equal( - '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","object":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', + '[fb3dd1ebc0e6c58f] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","data":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}\n', ); verify(packetMapper.toDomain(anything())).twice(); @@ -63,7 +63,7 @@ describe('ReadCommand', function () { sequence: 'fb3dd1ebc0e6c58f', payload: { opcode: 'DEVICE_GETTIME_RSP', - object: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }, }, { @@ -74,7 +74,7 @@ describe('ReadCommand', function () { sequence: 'fb3dd1ebc0e6c58f', payload: { opcode: 'DEVICE_GETTIME_RSP', - object: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: +0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }, }, ]); diff --git a/packages/cli/src/commands/read.command.ts b/packages/cli/src/commands/read.command.ts index 7650058c..feb86b13 100644 --- a/packages/cli/src/commands/read.command.ts +++ b/packages/cli/src/commands/read.command.ts @@ -20,6 +20,7 @@ export class ReadCommand implements Command { let pcap; try { + /* eslint import/no-extraneous-dependencies: ["error", {"optionalDependencies": true}] */ pcap = await import('pcap'); } catch (e) { /* istanbul ignore next */ diff --git a/packages/cli/src/commands/repl.command.test.ts b/packages/cli/src/commands/repl.command.test.ts new file mode 100644 index 00000000..d5279174 --- /dev/null +++ b/packages/cli/src/commands/repl.command.test.ts @@ -0,0 +1,100 @@ +import { imock, verify, instance, anything, when, capture } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { restore } from 'mock-fs'; +import { REPLCommand } from './repl.command'; +import type { Server } from '@agnoc/toolkit'; +import type { Context } from 'node:vm'; +import type { REPLServer } from 'repl'; + +describe('REPLCommand', function () { + let server: Server; + let repl: REPLServer; + let command: REPLCommand; + let historyFilename: string; + + beforeEach(function () { + server = imock(); + repl = imock(); + historyFilename = 'historyFilename'; + command = new REPLCommand(instance(server), instance(repl)); + }); + + afterEach(function () { + restore(); + }); + + it('should start a server', async function () { + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(null, instance(repl)), + ); + + await command.action({ historyFilename }); + + verify(server.listen()).once(); + }); + + it('should set repl context', async function () { + const context: Context = {}; + + when(repl.context).thenReturn(context); + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(null, instance(repl)), + ); + + await command.action({ historyFilename }); + + expect(context.server).to.be.equal(instance(server)); + }); + + it('should set repl history', async function () { + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(null, instance(repl)), + ); + + await command.action({ historyFilename }); + + verify(repl.setupHistory(historyFilename, anything())).once(); + }); + + it('should throw an error when history cannot be set', async function () { + const error = new Error('unexpected'); + + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(error, instance(repl)), + ); + + await expect(command.action({ historyFilename })).to.be.rejectedWith(error); + }); + + it('should close the server on exit event', async function () { + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(null, instance(repl)), + ); + + await command.action({ historyFilename }); + + const [, onExit] = capture<'exit', () => void>(repl.on).second(); + + onExit(); + + verify(server.close()).once(); + }); + + it('should set the context on reset event', async function () { + const context: Context = {}; + + when(repl.setupHistory(anything(), anything())).thenCall((_, callback: SetupHistoryCallback) => + callback(null, instance(repl)), + ); + + await command.action({ historyFilename }); + + const [, onReset] = capture<'reset', (context: Context) => void>(repl.on).first(); + + onReset(context); + + expect(context.server).to.be.equal(instance(server)); + }); +}); + +type SetupHistoryCallback = (err: Error | null, repl: REPLServer) => void; diff --git a/packages/cli/src/commands/repl.command.ts b/packages/cli/src/commands/repl.command.ts new file mode 100644 index 00000000..b0394f27 --- /dev/null +++ b/packages/cli/src/commands/repl.command.ts @@ -0,0 +1,45 @@ +import { promisify } from 'util'; +import * as Domain from '@agnoc/domain'; +import type { Command } from '../interfaces/command'; +import type { Server } from '@agnoc/toolkit'; +import type { REPLServer } from 'node:repl'; +import type { Context } from 'node:vm'; + +export interface REPLCommandOptions { + historyFilename: string; +} + +export class REPLCommand implements Command { + constructor(private readonly server: Server, private readonly repl: REPLServer) {} + + async action(options: REPLCommandOptions): Promise { + this.setContext(this.repl.context); + this.addListeners(); + + await this.setHistory(options.historyFilename); + await this.server.listen(); + } + + private addListeners() { + this.repl.on('reset', (context) => { + this.setContext(context); + }); + + this.repl.on('exit', () => { + void this.server.close(); + }); + } + + private setContext(context: Context) { + Object.assign(context, { + ...Domain, + server: this.server, + }); + } + + private setHistory(historyFilename: string) { + const setupHistory = promisify(this.repl.setupHistory.bind(this.repl)); + + return setupHistory(historyFilename); + } +} diff --git a/packages/cli/src/streams/array-transform.stream.test.ts b/packages/cli/src/streams/array-transform.stream.test.ts index c9ae9ec7..0c517e39 100644 --- a/packages/cli/src/streams/array-transform.stream.test.ts +++ b/packages/cli/src/streams/array-transform.stream.test.ts @@ -1,5 +1,5 @@ +import { readStream } from '@agnoc/test-support'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { ArrayTransform } from './array-transform.stream'; describe('ArrayTransform', function () { diff --git a/packages/cli/src/streams/json-stringify-transform.stream.test.ts b/packages/cli/src/streams/json-stringify-transform.stream.test.ts index 4af5f39d..e29bbb06 100644 --- a/packages/cli/src/streams/json-stringify-transform.stream.test.ts +++ b/packages/cli/src/streams/json-stringify-transform.stream.test.ts @@ -1,5 +1,5 @@ +import { readStream } from '@agnoc/test-support'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { JSONStringifyTransform } from './json-stringify-transform.stream'; describe('JSONStringifystream', function () { diff --git a/packages/cli/src/streams/json-transform.stream.test.ts b/packages/cli/src/streams/json-transform.stream.test.ts index ddaa9757..9a029d7e 100644 --- a/packages/cli/src/streams/json-transform.stream.test.ts +++ b/packages/cli/src/streams/json-transform.stream.test.ts @@ -1,5 +1,5 @@ +import { readStream } from '@agnoc/test-support'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { JSONTransform } from './json-transform.stream'; describe('JSONTransform', function () { diff --git a/packages/cli/src/streams/packet-decode-transform.stream.test.ts b/packages/cli/src/streams/packet-decode-transform.stream.test.ts index ac8d27ca..0049b64c 100644 --- a/packages/cli/src/streams/packet-decode-transform.stream.test.ts +++ b/packages/cli/src/streams/packet-decode-transform.stream.test.ts @@ -1,9 +1,9 @@ +import { readStream } from '@agnoc/test-support'; import { DomainException } from '@agnoc/toolkit'; import { Packet } from '@agnoc/transport-tcp'; import { givenSomePacketProps } from '@agnoc/transport-tcp/test-support'; import { verify, anything, imock, instance, when } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { PacketDecodeTransform } from './packet-decode-transform.stream'; import type { PacketMapper } from '@agnoc/transport-tcp'; diff --git a/packages/cli/src/streams/packet-encode-transform.stream.test.ts b/packages/cli/src/streams/packet-encode-transform.stream.test.ts index 5dce8488..18e98cb9 100644 --- a/packages/cli/src/streams/packet-encode-transform.stream.test.ts +++ b/packages/cli/src/streams/packet-encode-transform.stream.test.ts @@ -1,8 +1,9 @@ +import { readStream } from '@agnoc/test-support'; import { ID } from '@agnoc/toolkit'; import { OPCode, Packet, PacketSequence, Payload } from '@agnoc/transport-tcp'; import { anything, imock, instance, when, verify, deepEqual } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { givenAJSONPacket, readStream } from '../test-support'; +import { givenAJSONPacket } from '../test-support'; import { PacketEncodeTransform } from './packet-encode-transform.stream'; import type { PacketMapper } from '@agnoc/transport-tcp'; @@ -38,7 +39,7 @@ describe('PacketEncodeTransform', function () { sequence: PacketSequence.fromString(jsonPacket.sequence), payload: new Payload({ opcode: OPCode.fromName(jsonPacket.payload.opcode), - object: jsonPacket.payload.object, + data: jsonPacket.payload.data, }), }), ), diff --git a/packages/cli/src/streams/packet-encode-transform.stream.ts b/packages/cli/src/streams/packet-encode-transform.stream.ts index 08976960..acf0407e 100644 --- a/packages/cli/src/streams/packet-encode-transform.stream.ts +++ b/packages/cli/src/streams/packet-encode-transform.stream.ts @@ -1,7 +1,7 @@ import { Transform } from 'stream'; import { ID } from '@agnoc/toolkit'; import { OPCode, Packet, PacketSequence, Payload } from '@agnoc/transport-tcp'; -import type { PayloadObjectName, PacketProps, JSONPayload, PacketMapper } from '@agnoc/transport-tcp'; +import type { PayloadDataName, PacketProps, JSONPayload, PacketMapper } from '@agnoc/transport-tcp'; import type { TransformCallback } from 'stream'; export class PacketEncodeTransform extends Transform { @@ -16,21 +16,21 @@ export class PacketEncodeTransform extends Transform { done(); } - buildPacketFromJSON(serialized: JSONPacket): Packet { + buildPacketFromJSON(serialized: JSONPacket): Packet { const props: PacketProps = { ctype: serialized.ctype, flow: serialized.flow, deviceId: new ID(serialized.deviceId), userId: new ID(serialized.userId), sequence: PacketSequence.fromString(serialized.sequence), - payload: new Payload({ opcode: OPCode.fromName(serialized.payload.opcode), object: serialized.payload.object }), + payload: new Payload({ opcode: OPCode.fromName(serialized.payload.opcode), data: serialized.payload.data }), }; return new Packet(props); } } -export interface JSONPacket { +export interface JSONPacket { ctype: number; flow: number; deviceId: number; diff --git a/packages/cli/src/streams/pcap-reader.stream.test.ts b/packages/cli/src/streams/pcap-reader.stream.test.ts index f9bc9029..18a09a07 100644 --- a/packages/cli/src/streams/pcap-reader.stream.test.ts +++ b/packages/cli/src/streams/pcap-reader.stream.test.ts @@ -1,6 +1,6 @@ +import { readStream } from '@agnoc/test-support'; import { anything, fnmock, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { PCapReader } from './pcap-reader.stream'; import type { PCapReaderOptions } from './pcap-reader.stream'; import type { Decode, LiveSessionOptions, PacketWithHeader, PcapPacket, PcapSession } from 'pcap'; diff --git a/packages/cli/src/streams/pcap-transform.stream.test.ts b/packages/cli/src/streams/pcap-transform.stream.test.ts index 3691f4b2..49ccbc55 100644 --- a/packages/cli/src/streams/pcap-transform.stream.test.ts +++ b/packages/cli/src/streams/pcap-transform.stream.test.ts @@ -1,6 +1,6 @@ +import { readStream } from '@agnoc/test-support'; import { imock, instance, when } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { PCapTransform } from './pcap-transform.stream'; import type { EthernetPacket, IPv4, PcapPacket, TCP } from 'pcap'; diff --git a/packages/cli/src/streams/string-transform.stream.test.ts b/packages/cli/src/streams/string-transform.stream.test.ts index 078f6e1e..8e42e10c 100644 --- a/packages/cli/src/streams/string-transform.stream.test.ts +++ b/packages/cli/src/streams/string-transform.stream.test.ts @@ -1,5 +1,5 @@ +import { readStream } from '@agnoc/test-support'; import { expect } from 'chai'; -import { readStream } from '../test-support'; import { StringTransform } from './string-transform.stream'; describe('StringTransform', function () { diff --git a/packages/cli/src/test-support.ts b/packages/cli/src/test-support.ts index 75dba7f4..98d638d2 100644 --- a/packages/cli/src/test-support.ts +++ b/packages/cli/src/test-support.ts @@ -1,21 +1,4 @@ import type { JSONPacket } from './streams/packet-encode-transform.stream'; -import type { Readable } from 'stream'; - -export function readStream(stream: Readable): Promise; -export function readStream(stream: Readable, encoding: BufferEncoding): Promise; -export function readStream(stream: Readable, encoding?: BufferEncoding): Promise<(T | string)[]> { - if (encoding) { - stream.setEncoding(encoding); - } - - return new Promise((resolve, reject) => { - const data: unknown[] = []; - - stream.on('data', (chunk) => data.push(chunk)); - stream.on('end', () => resolve(data as T[])); - stream.on('error', (error) => reject(error)); - }); -} export function givenAJSONPacket(): JSONPacket<'DEVICE_GETTIME_RSP'> { return { @@ -26,7 +9,7 @@ export function givenAJSONPacket(): JSONPacket<'DEVICE_GETTIME_RSP'> { sequence: '7a479a0fbb978c12', payload: { opcode: 'DEVICE_GETTIME_RSP', - object: { + data: { result: 1, body: { deviceTime: 2, diff --git a/packages/cli/test/acceptance/cli.test.ts b/packages/cli/test/acceptance/cli.test.ts index 8693c6ae..065c2ff7 100644 --- a/packages/cli/test/acceptance/cli.test.ts +++ b/packages/cli/test/acceptance/cli.test.ts @@ -45,4 +45,10 @@ describe('cli', function () { expect(stdout).to.include('Usage: agnoc wlan:config'); }); + + it('has a repl command', async function () { + const { stdout } = await execa('node', [filename, 'repl', '-h']); + + expect(stdout).to.include('Usage: agnoc repl'); + }); }); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index 2ffae57e..8dbb7c0f 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -7,5 +7,11 @@ }, "include": ["src", "types"], "exclude": ["**/*.test.ts"], - "references": [{ "path": "../toolkit/tsconfig.build.json" }, { "path": "../transport-tcp/tsconfig.build.json" }] + "references": [ + { "path": "../toolkit/tsconfig.build.json" }, + { "path": "../transport-tcp/tsconfig.build.json" }, + { "path": "../domain/tsconfig.build.json" }, + { "path": "../adapter-tcp/tsconfig.build.json" }, + { "path": "../core/tsconfig.build.json" } + ] } diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 00000000..419a20a5 --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,5 @@ +*.tgz +.nyc_output/ +lib/ +coverage/ +node_modules/ diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..124c8f57 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,3 @@ +# @agnoc/core + +The core component of the library. diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..6b760dc3 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,88 @@ +{ + "name": "@agnoc/core", + "version": "0.18.0-next.0", + "description": "Agnoc adapter TCP library", + "keywords": [ + "agnoc", + "adapter", + "tcp", + "conga", + "cecotec", + "driver", + "reverse", + "engineering" + ], + "homepage": "https://github.com/adrigzr/agnoc", + "bugs": { + "url": "https://github.com/adrigzr/agnoc/issues" + }, + "license": "MIT", + "author": "Adrián González Rus (https://github.com/adrigzr)", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "files": [ + "lib" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/adrigzr/agnoc.git", + "directory": "packages/core" + }, + "scripts": { + "build": "npm-run-all build:lib", + "build:lib": "tsc -b tsconfig.build.json", + "clean": "rm -rf .build-cache lib coverage", + "posttest": "test \"$(cat coverage/coverage-summary.json | json total.lines.total)\" -gt 0", + "test": "nyc mocha src test" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "{workspaceRoot}/package.json", + "{workspaceRoot}/tsconfig.*", + "{projectRoot}/src/**/*", + "{projectRoot}/package.json", + "{projectRoot}/tsconfig.*" + ], + "outputs": [ + "{projectRoot}/lib", + "{projectRoot}/.build-cache" + ] + }, + "test": { + "dependsOn": [ + "build" + ], + "inputs": [ + "{workspaceRoot}/.mocharc.yml", + "{workspaceRoot}/nyc.config.js", + "{projectRoot}/src/**/*" + ], + "outputs": [ + "{projectRoot}/coverage" + ] + } + } + }, + "dependencies": { + "@agnoc/domain": "^0.18.0-next.0", + "@agnoc/toolkit": "^0.18.0-next.0", + "tslib": "^2.5.0" + }, + "devDependencies": { + "@johanblumenberg/ts-mockito": "^1.0.35", + "chai": "^4.3.7" + }, + "engines": { + "node": ">=18.12" + }, + "typedoc": { + "entryPoint": "./src/index.ts", + "readmeFile": "./README.md", + "displayName": "Core" + } +} diff --git a/packages/core/src/agnoc.server.test.ts b/packages/core/src/agnoc.server.test.ts new file mode 100644 index 00000000..f3b31d11 --- /dev/null +++ b/packages/core/src/agnoc.server.test.ts @@ -0,0 +1,91 @@ +import { ConnectionRepository, DeviceRepository, LocateDeviceCommand } from '@agnoc/domain'; +import { EventHandlerRegistry, ID, TaskHandlerRegistry } from '@agnoc/toolkit'; +import { capture, fnmock, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { AgnocServer } from './agnoc.server'; +import type { SubscribeHandler } from './agnoc.server'; +import type { Device, DomainEventBus, DeviceLockedDomainEvent } from '@agnoc/domain'; +import type { Server, TaskHandler } from '@agnoc/toolkit'; + +describe('AgnocServer', function () { + let server: Server; + let agnocServer: AgnocServer; + + beforeEach(function () { + server = imock(); + agnocServer = new AgnocServer(); + }); + + it('should provide a container to build an adapter', function () { + agnocServer.buildAdapter((container) => { + expect(container.deviceRepository).to.be.instanceOf(DeviceRepository); + expect(container.connectionRepository).to.be.instanceOf(ConnectionRepository); + expect(container.domainEventHandlerRegistry).to.be.instanceOf(EventHandlerRegistry); + expect(container.commandQueryHandlerRegistry).to.be.instanceOf(TaskHandlerRegistry); + + return instance(server); + }); + }); + + it('should listen adapters', async function () { + const options = { host: '127.0.0.1' }; + + agnocServer.buildAdapter(() => { + return instance(server); + }); + + await agnocServer.listen(options); + + verify(server.listen(options)).once(); + }); + + it('should close adapters', async function () { + agnocServer.buildAdapter(() => { + return instance(server); + }); + + await agnocServer.close(); + + verify(server.close()).once(); + }); + + it('should subscribe to domain events', async function () { + const device: Device = imock(); + const event: DeviceLockedDomainEvent = imock(); + const handler: SubscribeHandler<'DeviceLockedDomainEvent'> = fnmock(); + + agnocServer.subscribe('DeviceLockedDomainEvent', instance(handler)); + + agnocServer.buildAdapter(({ deviceRepository }) => { + void deviceRepository.saveOne(instance(device)); + + return instance(server); + }); + + // Extract the eventBus from the `device.publishEvents` method. + // This is just a way to obtain the eventBus to manually publish events. + const args = capture(device.publishEvents).first(); + const eventBus = args[0] as DomainEventBus; + + await eventBus.emit('DeviceLockedDomainEvent', instance(event)); + + verify(handler(instance(event))).once(); + }); + + it('should trigger commands', async function () { + const taskHandler: TaskHandler = imock(); + const command = new LocateDeviceCommand({ deviceId: new ID(1) }); + + when(taskHandler.forName).thenReturn('LocateDeviceCommand'); + + agnocServer.buildAdapter(({ commandQueryHandlerRegistry: commandHandlerRegistry }) => { + commandHandlerRegistry.register(instance(taskHandler)); + + return instance(server); + }); + + await agnocServer.trigger(command); + + verify(taskHandler.handle(command)).once(); + }); +}); diff --git a/packages/core/src/agnoc.server.ts b/packages/core/src/agnoc.server.ts new file mode 100644 index 00000000..8727ae9d --- /dev/null +++ b/packages/core/src/agnoc.server.ts @@ -0,0 +1,77 @@ +import { CommandQueryBus, ConnectionRepository, DeviceRepository, DomainEventBus } from '@agnoc/domain'; +import { EventHandlerRegistry, MemoryAdapter, TaskHandlerRegistry } from '@agnoc/toolkit'; +import { FindDeviceQueryHandler } from './query-handlers/find-device.query-handler'; +import { FindDevicesQueryHandler } from './query-handlers/find-devices.query-handler'; +import type { DomainEventNames, DomainEvents, CommandsOrQueries } from '@agnoc/domain'; +import type { Server, TaskOutput } from '@agnoc/toolkit'; + +export class AgnocServer implements Server { + private readonly domainEventBus: DomainEventBus; + private readonly domainEventHandlerRegistry: EventHandlerRegistry; + private readonly commandQueryBus: CommandQueryBus; + private readonly commandQueryHandlerRegistry: TaskHandlerRegistry; + private readonly deviceRepository: DeviceRepository; + private readonly connectionRepository: ConnectionRepository; + private readonly adapters = new Set(); + + constructor() { + this.domainEventBus = new DomainEventBus(); + this.domainEventHandlerRegistry = new EventHandlerRegistry(this.domainEventBus); + this.commandQueryBus = new CommandQueryBus(); + this.commandQueryHandlerRegistry = new TaskHandlerRegistry(this.commandQueryBus); + this.deviceRepository = new DeviceRepository(this.domainEventBus, new MemoryAdapter()); + this.connectionRepository = new ConnectionRepository(this.domainEventBus, new MemoryAdapter()); + this.registerHandlers(); + } + + subscribe(eventName: Name, handler: SubscribeHandler): void { + this.domainEventBus.on(eventName, handler); + } + + trigger( + command: CommandOrQuery, + ): Promise> { + return this.commandQueryBus.trigger(command); + } + + buildAdapter(builder: AdapterFactory): void { + const adapter = builder({ + domainEventHandlerRegistry: this.domainEventHandlerRegistry, + commandQueryHandlerRegistry: this.commandQueryHandlerRegistry, + deviceRepository: this.deviceRepository, + connectionRepository: this.connectionRepository, + }); + + this.adapters.add(adapter); + } + + async listen(options?: AgnocServerListenOptions): Promise { + await Promise.all([...this.adapters].map((adapter) => adapter.listen(options))); + } + + async close(): Promise { + await Promise.all([...this.adapters].map((adapter) => adapter.close())); + } + + private registerHandlers(): void { + this.commandQueryHandlerRegistry.register( + new FindDeviceQueryHandler(this.deviceRepository), + new FindDevicesQueryHandler(this.deviceRepository), + ); + } +} + +export interface AgnocServerListenOptions { + host?: string; +} + +type AdapterFactory = (container: Container) => Server; + +export type Container = { + domainEventHandlerRegistry: EventHandlerRegistry; + commandQueryHandlerRegistry: TaskHandlerRegistry; + deviceRepository: DeviceRepository; + connectionRepository: ConnectionRepository; +}; + +export type SubscribeHandler = (event: DomainEvents[Name]) => Promise; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..3fa0af59 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,2 @@ +export * from './agnoc.server'; +export * from './query-handlers/find-device.query-handler'; diff --git a/packages/core/src/query-handlers/find-device.query-handler.test.ts b/packages/core/src/query-handlers/find-device.query-handler.test.ts new file mode 100644 index 00000000..5b27052c --- /dev/null +++ b/packages/core/src/query-handlers/find-device.query-handler.test.ts @@ -0,0 +1,48 @@ +import { Device, FindDeviceQuery } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { ID } from '@agnoc/toolkit'; +import { anything, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { FindDeviceQueryHandler } from './find-device.query-handler'; +import type { DeviceRepository } from '@agnoc/domain'; + +describe('FindDeviceQueryHandler', function () { + let deviceRepository: DeviceRepository; + let queryHandler: FindDeviceQueryHandler; + + beforeEach(function () { + deviceRepository = imock(); + queryHandler = new FindDeviceQueryHandler(instance(deviceRepository)); + }); + + it('should define the name', function () { + expect(queryHandler.forName).to.be.equal('FindDeviceQuery'); + }); + + describe('#handle()', function () { + it('should find a device', async function () { + const deviceId = new ID(1); + const device = new Device(givenSomeDeviceProps()); + const query = new FindDeviceQuery({ deviceId }); + + when(deviceRepository.findOneById(anything())).thenResolve(device); + + const result = await queryHandler.handle(query); + + expect(result).to.be.deep.equal({ device }); + + verify(deviceRepository.findOneById(deviceId)).once(); + }); + + it("throw an error when 'device' is not found", async function () { + const deviceId = new ID(1); + const query = new FindDeviceQuery({ deviceId }); + + when(deviceRepository.findOneById(anything())).thenResolve(undefined); + + await expect(queryHandler.handle(query)).to.be.rejectedWith(`Unable to find a device with id ${deviceId.value}`); + + verify(deviceRepository.findOneById(deviceId)).once(); + }); + }); +}); diff --git a/packages/core/src/query-handlers/find-device.query-handler.ts b/packages/core/src/query-handlers/find-device.query-handler.ts new file mode 100644 index 00000000..34dfd80d --- /dev/null +++ b/packages/core/src/query-handlers/find-device.query-handler.ts @@ -0,0 +1,18 @@ +import { DomainException } from '@agnoc/toolkit'; +import type { DeviceRepository, FindDeviceQuery, FindDeviceQueryOutput, QueryHandler } from '@agnoc/domain'; + +export class FindDeviceQueryHandler implements QueryHandler { + readonly forName = 'FindDeviceQuery'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(event: FindDeviceQuery): Promise { + const device = await this.deviceRepository.findOneById(event.deviceId); + + if (!device) { + throw new DomainException(`Unable to find a device with id ${event.deviceId.value}`); + } + + return { device }; + } +} diff --git a/packages/core/src/query-handlers/find-devices.query-handler.test.ts b/packages/core/src/query-handlers/find-devices.query-handler.test.ts new file mode 100644 index 00000000..83877e78 --- /dev/null +++ b/packages/core/src/query-handlers/find-devices.query-handler.test.ts @@ -0,0 +1,35 @@ +import { Device, FindDevicesQuery } from '@agnoc/domain'; +import { givenSomeDeviceProps } from '@agnoc/domain/test-support'; +import { imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { FindDevicesQueryHandler } from './find-devices.query-handler'; +import type { DeviceRepository } from '@agnoc/domain'; + +describe('FindDevicesQueryHandler', function () { + let deviceRepository: DeviceRepository; + let queryHandler: FindDevicesQueryHandler; + + beforeEach(function () { + deviceRepository = imock(); + queryHandler = new FindDevicesQueryHandler(instance(deviceRepository)); + }); + + it('should define the name', function () { + expect(queryHandler.forName).to.be.equal('FindDevicesQuery'); + }); + + describe('#handle()', function () { + it('should find some devices', async function () { + const devices = [new Device(givenSomeDeviceProps())]; + const query = new FindDevicesQuery(); + + when(deviceRepository.findAll()).thenResolve(devices); + + const result = await queryHandler.handle(query); + + expect(result).to.be.deep.equal({ devices }); + + verify(deviceRepository.findAll()).once(); + }); + }); +}); diff --git a/packages/core/src/query-handlers/find-devices.query-handler.ts b/packages/core/src/query-handlers/find-devices.query-handler.ts new file mode 100644 index 00000000..3d72430f --- /dev/null +++ b/packages/core/src/query-handlers/find-devices.query-handler.ts @@ -0,0 +1,13 @@ +import type { QueryHandler, FindDevicesQueryOutput, DeviceRepository, FindDevicesQuery } from '@agnoc/domain'; + +export class FindDevicesQueryHandler implements QueryHandler { + readonly forName = 'FindDevicesQuery'; + + constructor(private readonly deviceRepository: DeviceRepository) {} + + async handle(_: FindDevicesQuery): Promise { + const devices = await this.deviceRepository.findAll(); + + return { devices }; + } +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000..d9b2d985 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "tsBuildInfoFile": ".build-cache/tsconfig.build.tsbuildinfo" + }, + "include": ["src"], + "exclude": ["**/*.test.ts"], + "references": [ + { "path": "../toolkit/tsconfig.build.json" }, + { "path": "../domain/tsconfig.build.json" }, + { "path": "../adapter-tcp/tsconfig.build.json" } + ] +} diff --git a/packages/domain/package.json b/packages/domain/package.json index 8c80557f..d8b304d4 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -79,6 +79,7 @@ "tslib": "^2.5.0" }, "devDependencies": { + "@johanblumenberg/ts-mockito": "^1.0.35", "chai": "^4.3.7" }, "engines": { diff --git a/packages/domain/src/aggregate-roots/connection.aggregate-root.test.ts b/packages/domain/src/aggregate-roots/connection.aggregate-root.test.ts new file mode 100644 index 00000000..f3034fb6 --- /dev/null +++ b/packages/domain/src/aggregate-roots/connection.aggregate-root.test.ts @@ -0,0 +1,134 @@ +import { AggregateRoot, ArgumentInvalidException, DomainException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { ConnectionDeviceChangedDomainEvent } from '../domain-events/connection-device-changed.domain-event'; +import { givenSomeDeviceProps, givenSomeConnectionProps } from '../test-support'; +import { Connection } from './connection.aggregate-root'; +import { Device } from './device.aggregate-root'; +import type { ConnectionProps } from './connection.aggregate-root'; + +describe('Connection', function () { + it('should be created', function () { + const props = givenSomeDeviceProps(); + const connection = new DummyConnection(props); + + expect(connection).to.be.instanceOf(AggregateRoot); + expect(connection.id).to.be.equal(props.id); + }); + + it('should be created with device', function () { + const device = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device }); + + expect(connection.device).to.be.equal(device); + }); + + it("should throw an error when 'deviceId' is not a Device", function () { + // @ts-expect-error - invalid property + expect(() => new DummyConnection({ ...givenSomeConnectionProps(), device: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'device' of DummyConnection is not an instance of Device`, + ); + }); + + describe('#setDevice', function () { + it('should set a device', function () { + const device = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device: undefined }); + + connection.setDevice(device); + + expect(connection.device).to.be.equal(device); + + const event = connection.domainEvents[0] as ConnectionDeviceChangedDomainEvent; + + expect(event).to.be.instanceOf(ConnectionDeviceChangedDomainEvent); + expect(event.aggregateId).to.be.equal(connection.id); + expect(event.previousDeviceId).to.be.equal(undefined); + expect(event.currentDeviceId).to.be.equal(device.id); + }); + + it('should override a device', function () { + const deviceA = new Device(givenSomeDeviceProps()); + const deviceB = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device: deviceA }); + + connection.setDevice(deviceB); + + expect(connection.device).to.be.equal(deviceB); + + const event = connection.domainEvents[0] as ConnectionDeviceChangedDomainEvent; + + expect(event).to.be.instanceOf(ConnectionDeviceChangedDomainEvent); + expect(event.aggregateId).to.be.equal(connection.id); + expect(event.previousDeviceId).to.be.equal(deviceA.id); + expect(event.currentDeviceId).to.be.equal(deviceB.id); + }); + + it('should unset a device', function () { + const device = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device }); + + connection.setDevice(undefined); + + expect(connection.device).to.be.equal(undefined); + + const event = connection.domainEvents[0] as ConnectionDeviceChangedDomainEvent; + + expect(event).to.be.instanceOf(ConnectionDeviceChangedDomainEvent); + expect(event.aggregateId).to.be.equal(connection.id); + expect(event.previousDeviceId).to.be.equal(device.id); + expect(event.currentDeviceId).to.be.equal(undefined); + }); + + it('should do nothing when setting the same device', function () { + const device = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device }); + + connection.setDevice(device); + + expect(connection.device).to.be.equal(device); + expect(connection.domainEvents).to.be.empty; + }); + + it('should do nothing when setting nothing over nothing', function () { + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device: undefined }); + + connection.setDevice(undefined); + + expect(connection.device).to.be.equal(undefined); + expect(connection.domainEvents).to.be.empty; + }); + + it('should throw an error when setting not a Device', function () { + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device: undefined }); + + // @ts-expect-error - invalid property + expect(() => connection.setDevice('foo')).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'device' of DummyConnection is not an instance of Device`, + ); + }); + }); + + describe('#assertDevice()', function () { + it('should throw an error when the connection does not has a device set', function () { + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device: undefined }); + + expect(() => connection.assertDevice()).to.throw( + DomainException, + 'Connection does not have a reference to a device', + ); + }); + + it('should not throw an error when the connection has a device set', function () { + const device = new Device(givenSomeDeviceProps()); + const connection = new DummyConnection({ ...givenSomeConnectionProps(), device }); + + expect(() => connection.assertDevice()).to.not.throw(DomainException); + }); + }); +}); + +class DummyConnection extends Connection { + connectionType = 'Dummy'; +} diff --git a/packages/domain/src/aggregate-roots/connection.aggregate-root.ts b/packages/domain/src/aggregate-roots/connection.aggregate-root.ts new file mode 100644 index 00000000..8681f3b4 --- /dev/null +++ b/packages/domain/src/aggregate-roots/connection.aggregate-root.ts @@ -0,0 +1,52 @@ +import { AggregateRoot, DomainException } from '@agnoc/toolkit'; +import { ConnectionDeviceChangedDomainEvent } from '../domain-events/connection-device-changed.domain-event'; +import { Device } from './device.aggregate-root'; +import type { EntityProps } from '@agnoc/toolkit'; + +export interface ConnectionProps extends EntityProps { + device?: Device; +} + +export interface ConnectionWithDevice extends Connection { + device: T; +} + +export abstract class Connection extends AggregateRoot { + abstract readonly connectionType: string; + + constructor(props: Props) { + super(props); + } + + get device(): Device | undefined { + return this.props.device; + } + + setDevice(device: Device | undefined): void { + if (this.device === device || this.device?.equals(device)) { + return; + } + + if (device) { + this.validateInstanceProp({ device }, 'device', Device); + } + + const previousDeviceId = this.device?.id; + const currentDeviceId = device?.id; + + this.props.device = device; + this.addEvent(new ConnectionDeviceChangedDomainEvent({ aggregateId: this.id, previousDeviceId, currentDeviceId })); + } + + assertDevice(): asserts this is Connection & ConnectionWithDevice { + if (!this.device) { + throw new DomainException('Connection does not have a reference to a device'); + } + } + + protected validate(props: Props): void { + if (props.device) { + this.validateInstanceProp(props, 'device', Device); + } + } +} diff --git a/packages/domain/src/aggregate-roots/device.aggregate-root.test.ts b/packages/domain/src/aggregate-roots/device.aggregate-root.test.ts new file mode 100644 index 00000000..507d18ce --- /dev/null +++ b/packages/domain/src/aggregate-roots/device.aggregate-root.test.ts @@ -0,0 +1,759 @@ +import { AggregateRoot, ArgumentInvalidException, ArgumentNotProvidedException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceBatteryChangedDomainEvent } from '../domain-events/device-battery-changed.domain-event'; +import { DeviceCleanWorkChangedDomainEvent } from '../domain-events/device-clean-work-changed.domain-event'; +import { DeviceConnectedDomainEvent } from '../domain-events/device-connected.domain-event'; +import { DeviceConsumablesChangedDomainEvent } from '../domain-events/device-consumables-changed.domain-event'; +import { DeviceCreatedDomainEvent } from '../domain-events/device-created.domain-event'; +import { DeviceErrorChangedDomainEvent } from '../domain-events/device-error-changed.domain-event'; +import { DeviceFanSpeedChangedDomainEvent } from '../domain-events/device-fan-speed-changed.domain-event'; +import { DeviceLockedDomainEvent } from '../domain-events/device-locked.domain-event'; +import { DeviceMapChangedDomainEvent } from '../domain-events/device-map-changed.domain-event'; +import { DeviceMapPendingDomainEvent } from '../domain-events/device-map-pending.domain-event'; +import { DeviceModeChangedDomainEvent } from '../domain-events/device-mode-changed.domain-event'; +import { DeviceMopAttachedDomainEvent } from '../domain-events/device-mop-attached.domain-event'; +import { DeviceNetworkChangedDomainEvent } from '../domain-events/device-network-changed.domain-event'; +import { DeviceOrdersChangedDomainEvent } from '../domain-events/device-orders-changed.domain-event'; +import { DeviceSettingsChangedDomainEvent } from '../domain-events/device-settings-changed.domain-event'; +import { DeviceStateChangedDomainEvent } from '../domain-events/device-state-changed.domain-event'; +import { DeviceVersionChangedDomainEvent } from '../domain-events/device-version-changed.domain-event'; +import { DeviceWaterLevelChangedDomainEvent } from '../domain-events/device-water-level-changed.domain-event'; +import { CleanSize } from '../domain-primitives/clean-size.domain-primitive'; +import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; +import { DeviceError, DeviceErrorValue } from '../domain-primitives/device-error.domain-primitive'; +import { DeviceFanSpeed, DeviceFanSpeedValue } from '../domain-primitives/device-fan-speed.domain-primitive'; +import { DeviceMode, DeviceModeValue } from '../domain-primitives/device-mode.domain-primitive'; +import { DeviceState, DeviceStateValue } from '../domain-primitives/device-state.domain-primitive'; +import { DeviceWaterLevel, DeviceWaterLevelValue } from '../domain-primitives/device-water-level.domain-primitive'; +import { DeviceMap } from '../entities/device-map.entity'; +import { DeviceOrder } from '../entities/device-order.entity'; +import { + givenSomeDeviceCleanWorkProps, + givenSomeDeviceConsumableProps, + givenSomeDeviceMapProps, + givenSomeDeviceOrderProps, + givenSomeDeviceProps, + givenSomeDeviceSettingsProps, + givenSomeDeviceVersionProps, + givenSomeDeviceNetworkProps, +} from '../test-support'; +import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; +import { DeviceNetwork } from '../value-objects/device-network.value-object'; +import { DeviceSetting } from '../value-objects/device-setting.value-object'; +import { DeviceSettings } from '../value-objects/device-settings.value-object'; +import { DeviceVersion } from '../value-objects/device-version.value-object'; +import { Device } from './device.aggregate-root'; + +describe('Device', function () { + it('should be created', function () { + const deviceProps = { + ...givenSomeDeviceProps(), + orders: [new DeviceOrder(givenSomeDeviceOrderProps())], + consumables: [new DeviceConsumable(givenSomeDeviceConsumableProps())], + }; + const device = new Device(deviceProps); + + expect(device).to.be.instanceOf(AggregateRoot); + expect(device.id).to.be.equal(deviceProps.id); + expect(device.userId).to.be.equal(deviceProps.userId); + expect(device.system).to.be.equal(deviceProps.system); + expect(device.version).to.be.equal(deviceProps.version); + expect(device.battery).to.be.equal(deviceProps.battery); + expect(device.isConnected).to.be.equal(deviceProps.isConnected); + expect(device.isLocked).to.be.equal(deviceProps.isLocked); + + const event = device.domainEvents[0] as DeviceCreatedDomainEvent; + + expect(event).to.be.instanceOf(DeviceCreatedDomainEvent); + expect(event.aggregateId).to.be.equal(device.id); + }); + + it("should throw an error when 'userId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), userId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'userId' for Device not provided`, + ); + }); + + it("should throw an error when 'system' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), system: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'system' for Device not provided`, + ); + }); + + it("should throw an error when 'version' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), version: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'version' for Device not provided`, + ); + }); + + it("should throw an error when 'battery' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), battery: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'battery' for Device not provided`, + ); + }); + + it("should throw an error when 'isConnected' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), isConnected: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'isConnected' for Device not provided`, + ); + }); + + it("should throw an error when 'isLocked' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new Device({ ...givenSomeDeviceProps(), isLocked: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'isLocked' for Device not provided`, + ); + }); + + it("should throw an error when 'userId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), userId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'userId' of Device is not an instance of ID`, + ); + }); + + it("should throw an error when 'system' is not a DeviceSystem", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), system: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'system' of Device is not an instance of DeviceSystem`, + ); + }); + + it("should throw an error when 'version' is not a DeviceVersion", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), version: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'version' of Device is not an instance of DeviceVersion`, + ); + }); + + it("should throw an error when 'isConnected' is not a boolean", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), isConnected: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'isConnected' of Device is not a boolean`, + ); + }); + + it("should throw an error when 'isLocked' is not a boolean", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), isLocked: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'isLocked' of Device is not a boolean`, + ); + }); + + it("should throw an error when 'settings' is not a DeviceSettings", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), settings: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'settings' of Device is not an instance of DeviceSettings`, + ); + }); + + it("should throw an error when 'currentCleanWork' is not a DeviceCleanWork", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), currentCleanWork: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'currentCleanWork' of Device is not an instance of DeviceCleanWork`, + ); + }); + + it("should throw an error when 'orders' is not an array", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), orders: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'orders' of Device is not an array`, + ); + }); + + it("should throw an error when 'orders' is not an array of DeviceOrder", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), orders: ['foo', 1] })).to.throw( + ArgumentInvalidException, + `Value 'foo, 1' for property 'orders' of Device is not an array of DeviceOrder`, + ); + }); + + it("should throw an error when 'consumables' is not an array", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), consumables: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'consumables' of Device is not an array`, + ); + }); + + it("should throw an error when 'consumables' is not an array of DeviceConsumable", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), consumables: ['foo', 1] })).to.throw( + ArgumentInvalidException, + `Value 'foo, 1' for property 'consumables' of Device is not an array of DeviceConsumable`, + ); + }); + + it("should throw an error when 'map' is not a DeviceMap", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), map: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'map' of Device is not an instance of DeviceMap`, + ); + }); + + it("should throw an error when 'network' is not a DeviceNetwork", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), network: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'network' of Device is not an instance of DeviceNetwork`, + ); + }); + + it("should throw an error when 'battery' is not a DeviceBattery", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), battery: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'battery' of Device is not an instance of DeviceBattery`, + ); + }); + + it("should throw an error when 'state' is not a DeviceState", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), state: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'state' of Device is not an instance of DeviceState`, + ); + }); + + it("should throw an error when 'mode' is not a DeviceMode", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), mode: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'mode' of Device is not an instance of DeviceMode`, + ); + }); + + it("should throw an error when 'error' is not a DeviceError", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), error: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'error' of Device is not an instance of DeviceError`, + ); + }); + + it("should throw an error when 'fanSpeed' is not a DeviceFanSpeed", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), fanSpeed: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'fanSpeed' of Device is not an instance of DeviceFanSpeed`, + ); + }); + + it("should throw an error when 'waterLevel' is not a DeviceWaterLevel", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), waterLevel: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'waterLevel' of Device is not an instance of DeviceWaterLevel`, + ); + }); + + it("should throw an error when 'hasMopAttached' is not a boolean", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), hasMopAttached: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'hasMopAttached' of Device is not a boolean`, + ); + }); + + it("should throw an error when 'hasWaitingMap' is not a boolean", function () { + // @ts-expect-error - invalid property + expect(() => new Device({ ...givenSomeDeviceProps(), hasWaitingMap: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'hasWaitingMap' of Device is not a boolean`, + ); + }); + + describe('#setAsConnected()', function () { + it('should set the device as connected', function () { + const device = new Device({ ...givenSomeDeviceProps(), isConnected: false }); + + device.setAsConnected(); + + expect(device.isConnected).to.be.true; + + const event = device.domainEvents[1] as DeviceConnectedDomainEvent; + + expect(event).to.be.instanceOf(DeviceConnectedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + }); + + it('should not set the device as connected when is connected', function () { + const device = new Device({ ...givenSomeDeviceProps(), isConnected: true }); + + device.setAsConnected(); + + expect(device.isConnected).to.be.true; + + expect(device.domainEvents[1]).to.not.be.instanceOf(DeviceConnectedDomainEvent); + }); + }); + + describe('#setAsLocked()', function () { + it('should set the device as locked', function () { + const device = new Device({ ...givenSomeDeviceProps(), isLocked: false }); + + device.setAsLocked(); + + expect(device.isLocked).to.be.true; + + const event = device.domainEvents[1] as DeviceLockedDomainEvent; + + expect(event).to.be.instanceOf(DeviceLockedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + }); + + it('should not set the device as locked', function () { + const device = new Device({ ...givenSomeDeviceProps(), isLocked: true }); + + device.setAsLocked(); + + expect(device.isLocked).to.be.true; + expect(device.domainEvents[1]).to.not.be.instanceOf(DeviceLockedDomainEvent); + }); + }); + + describe('#updateVersion()', function () { + it('should update the device version', function () { + const previousVersion = new DeviceVersion({ ...givenSomeDeviceVersionProps(), software: '1.0.0' }); + const currentVersion = new DeviceVersion({ ...givenSomeDeviceVersionProps(), software: '1.0.1' }); + const device = new Device({ ...givenSomeDeviceProps(), version: previousVersion }); + + device.updateVersion(currentVersion); + + expect(device.version).to.be.equal(currentVersion); + + const event = device.domainEvents[1] as DeviceVersionChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceVersionChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousVersion).to.be.equal(previousVersion); + expect(event.currentVersion).to.be.equal(currentVersion); + }); + + it('should not update the device version when value is equal', function () { + const previousVersion = new DeviceVersion(givenSomeDeviceVersionProps()); + const currentVersion = new DeviceVersion(givenSomeDeviceVersionProps()); + const device = new Device({ ...givenSomeDeviceProps(), version: previousVersion }); + + device.updateVersion(currentVersion); + + expect(device.version).to.be.equal(previousVersion); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateSettings()', function () { + it('should update the device settings', function () { + const previousSettings = new DeviceSettings({ + ...givenSomeDeviceSettingsProps(), + ecoMode: new DeviceSetting({ isEnabled: false }), + }); + const currentSettings = new DeviceSettings({ + ...givenSomeDeviceSettingsProps(), + ecoMode: new DeviceSetting({ isEnabled: true }), + }); + const device = new Device({ ...givenSomeDeviceProps(), settings: previousSettings }); + + device.updateSettings(currentSettings); + + expect(device.settings).to.be.equal(currentSettings); + + const event = device.domainEvents[1] as DeviceSettingsChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceSettingsChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousSettings).to.be.equal(previousSettings); + expect(event.currentSettings).to.be.equal(currentSettings); + }); + + it('should not update the device settings when value is equal', function () { + const previousSettings = new DeviceSettings(givenSomeDeviceSettingsProps()); + const currentSettings = new DeviceSettings(givenSomeDeviceSettingsProps()); + const device = new Device({ ...givenSomeDeviceProps(), settings: previousSettings }); + + device.updateSettings(currentSettings); + + expect(device.settings).to.be.equal(previousSettings); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateCurrentCleanWork()', function () { + it('should update the device cleanWork', function () { + const previousCleanWork = new DeviceCleanWork({ ...givenSomeDeviceCleanWorkProps(), size: new CleanSize(1) }); + const currentCleanWork = new DeviceCleanWork({ ...givenSomeDeviceCleanWorkProps(), size: new CleanSize(2) }); + const device = new Device({ ...givenSomeDeviceProps(), currentCleanWork: previousCleanWork }); + + device.updateCurrentCleanWork(currentCleanWork); + + expect(device.currentCleanWork).to.be.equal(currentCleanWork); + + const event = device.domainEvents[1] as DeviceCleanWorkChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceCleanWorkChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousCleanWork).to.be.equal(previousCleanWork); + expect(event.currentCleanWork).to.be.equal(currentCleanWork); + }); + + it('should not update the device cleanWork when value is equal', function () { + const previousCleanWork = new DeviceCleanWork(givenSomeDeviceCleanWorkProps()); + const currentCleanWork = new DeviceCleanWork(givenSomeDeviceCleanWorkProps()); + const device = new Device({ ...givenSomeDeviceProps(), currentCleanWork: previousCleanWork }); + + device.updateCurrentCleanWork(currentCleanWork); + + expect(device.currentCleanWork).to.be.equal(previousCleanWork); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateOrders()', function () { + it('should update the device order', function () { + const currentOrders = [new DeviceOrder({ ...givenSomeDeviceOrderProps(), id: new ID(2) })]; + const device = new Device({ ...givenSomeDeviceProps(), orders: undefined }); + + device.updateOrders(currentOrders); + + expect(device.orders).to.be.equal(currentOrders); + + const event = device.domainEvents[1] as DeviceOrdersChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceOrdersChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousOrders).to.be.equal(undefined); + expect(event.currentOrders).to.be.equal(currentOrders); + }); + + it('should not update the device order when value is equal', function () { + const previousOrders = [new DeviceOrder({ ...givenSomeDeviceOrderProps(), id: new ID(1) })]; + const currentOrders = [new DeviceOrder({ ...givenSomeDeviceOrderProps(), id: new ID(1) })]; + const device = new Device({ ...givenSomeDeviceProps(), orders: previousOrders }); + + device.updateOrders(currentOrders); + + expect(device.orders).to.be.equal(previousOrders); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateConsumables()', function () { + it('should update the device consumables', function () { + const previousConsumables = [new DeviceConsumable(givenSomeDeviceConsumableProps())]; + const currentConsumables = [new DeviceConsumable(givenSomeDeviceConsumableProps())]; + const device = new Device({ ...givenSomeDeviceProps(), consumables: previousConsumables }); + + device.updateConsumables(currentConsumables); + + expect(device.consumables).to.be.equal(currentConsumables); + + const event = device.domainEvents[1] as DeviceConsumablesChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceConsumablesChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousConsumables).to.be.equal(previousConsumables); + expect(event.currentConsumables).to.be.equal(currentConsumables); + }); + }); + + describe('#updateMap()', function () { + it('should update the device map', function () { + const previousMap = new DeviceMap({ ...givenSomeDeviceMapProps(), id: new ID(1) }); + const currentMap = new DeviceMap({ ...givenSomeDeviceMapProps(), id: new ID(2) }); + const device = new Device({ ...givenSomeDeviceProps(), map: previousMap }); + + device.updateMap(currentMap); + + expect(device.map).to.be.equal(currentMap); + + const event = device.domainEvents[1] as DeviceMapChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceMapChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousMap).to.be.equal(previousMap); + expect(event.currentMap).to.be.equal(currentMap); + }); + }); + + describe('#updateNetwork()', function () { + it('should update the device network', function () { + const previousNetwork = new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), port: 1 }); + const currentNetwork = new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), port: 2 }); + const device = new Device({ ...givenSomeDeviceProps(), network: previousNetwork }); + + device.updateNetwork(currentNetwork); + + expect(device.network).to.be.equal(currentNetwork); + + const event = device.domainEvents[1] as DeviceNetworkChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceNetworkChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousNetwork).to.be.equal(previousNetwork); + expect(event.currentNetwork).to.be.equal(currentNetwork); + }); + + it('should not update the device network when value is equal', function () { + const previousNetwork = new DeviceNetwork(givenSomeDeviceNetworkProps()); + const currentNetwork = new DeviceNetwork(givenSomeDeviceNetworkProps()); + const device = new Device({ ...givenSomeDeviceProps(), network: previousNetwork }); + + device.updateNetwork(currentNetwork); + + expect(device.network).to.be.equal(previousNetwork); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateBattery()', function () { + it('should update the device battery', function () { + const previousBattery = new DeviceBattery(60); + const currentBattery = new DeviceBattery(50); + const device = new Device({ ...givenSomeDeviceProps(), battery: previousBattery }); + + device.updateBattery(currentBattery); + + expect(device.battery).to.be.equal(currentBattery); + + const event = device.domainEvents[1] as DeviceBatteryChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceBatteryChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousBattery).to.be.equal(previousBattery); + expect(event.currentBattery).to.be.equal(currentBattery); + }); + + it('should not update the device battery when value is equal', function () { + const previousBattery = new DeviceBattery(50); + const currentBattery = new DeviceBattery(50); + const device = new Device({ ...givenSomeDeviceProps(), battery: previousBattery }); + + device.updateBattery(currentBattery); + + expect(device.battery).to.be.equal(previousBattery); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateState()', function () { + it('should update the device state', function () { + const previousState = new DeviceState(DeviceStateValue.Idle); + const currentState = new DeviceState(DeviceStateValue.Docked); + const device = new Device({ ...givenSomeDeviceProps(), state: previousState }); + + device.updateState(currentState); + + expect(device.state).to.be.equal(currentState); + + const event = device.domainEvents[1] as DeviceStateChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceStateChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousState).to.be.equal(previousState); + expect(event.currentState).to.be.equal(currentState); + }); + + it('should not update the device state when value is equal', function () { + const previousState = new DeviceState(DeviceStateValue.Idle); + const currentState = new DeviceState(DeviceStateValue.Idle); + const device = new Device({ ...givenSomeDeviceProps(), state: previousState }); + + device.updateState(currentState); + + expect(device.state).to.be.equal(previousState); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateMode()', function () { + it('should update the device mode', function () { + const previousMode = new DeviceMode(DeviceModeValue.None); + const currentMode = new DeviceMode(DeviceModeValue.Mop); + const device = new Device({ ...givenSomeDeviceProps(), mode: previousMode }); + + device.updateMode(currentMode); + + expect(device.mode).to.be.equal(currentMode); + + const event = device.domainEvents[1] as DeviceModeChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceModeChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousMode).to.be.equal(previousMode); + expect(event.currentMode).to.be.equal(currentMode); + }); + + it('should not update the device mode when value is equal', function () { + const previousMode = new DeviceMode(DeviceModeValue.None); + const currentMode = new DeviceMode(DeviceModeValue.None); + const device = new Device({ ...givenSomeDeviceProps(), mode: previousMode }); + + device.updateMode(currentMode); + + expect(device.mode).to.be.equal(previousMode); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateError()', function () { + it('should update the device error', function () { + const previousError = new DeviceError(DeviceErrorValue.None); + const currentError = new DeviceError(DeviceErrorValue.WheelUp); + const device = new Device({ ...givenSomeDeviceProps(), error: previousError }); + + device.updateError(currentError); + + expect(device.error).to.be.equal(currentError); + + const event = device.domainEvents[1] as DeviceErrorChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceErrorChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousError).to.be.equal(previousError); + expect(event.currentError).to.be.equal(currentError); + }); + + it('should not update the device error when value is equal', function () { + const previousError = new DeviceError(DeviceErrorValue.None); + const currentError = new DeviceError(DeviceErrorValue.None); + const device = new Device({ ...givenSomeDeviceProps(), error: previousError }); + + device.updateError(currentError); + + expect(device.error).to.be.equal(previousError); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateFanSpeed()', function () { + it('should update the device fanSpeed', function () { + const previousFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Off); + const currentFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); + const device = new Device({ ...givenSomeDeviceProps(), fanSpeed: previousFanSpeed }); + + device.updateFanSpeed(currentFanSpeed); + + expect(device.fanSpeed).to.be.equal(currentFanSpeed); + + const event = device.domainEvents[1] as DeviceFanSpeedChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceFanSpeedChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousFanSpeed).to.be.equal(previousFanSpeed); + expect(event.currentFanSpeed).to.be.equal(currentFanSpeed); + }); + + it('should not update the device fanSpeed when value is equal', function () { + const previousFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Off); + const currentFanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Off); + const device = new Device({ ...givenSomeDeviceProps(), fanSpeed: previousFanSpeed }); + + device.updateFanSpeed(currentFanSpeed); + + expect(device.fanSpeed).to.be.equal(previousFanSpeed); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateWaterLevel()', function () { + it('should update the device waterLevel', function () { + const previousWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Off); + const currentWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Low); + const device = new Device({ ...givenSomeDeviceProps(), waterLevel: previousWaterLevel }); + + device.updateWaterLevel(currentWaterLevel); + + expect(device.waterLevel).to.be.equal(currentWaterLevel); + + const event = device.domainEvents[1] as DeviceWaterLevelChangedDomainEvent; + + expect(event).to.be.instanceOf(DeviceWaterLevelChangedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.previousWaterLevel).to.be.equal(previousWaterLevel); + expect(event.currentWaterLevel).to.be.equal(currentWaterLevel); + }); + + it('should not update the device waterLevel when value is equal', function () { + const previousWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Off); + const currentWaterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Off); + const device = new Device({ ...givenSomeDeviceProps(), waterLevel: previousWaterLevel }); + + device.updateWaterLevel(currentWaterLevel); + + expect(device.waterLevel).to.be.equal(previousWaterLevel); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateHasMopAttached()', function () { + it('should update the device hasMopAttached', function () { + const device = new Device({ ...givenSomeDeviceProps(), hasMopAttached: false }); + + device.updateHasMopAttached(true); + + expect(device.hasMopAttached).to.be.equal(true); + + const event = device.domainEvents[1] as DeviceMopAttachedDomainEvent; + + expect(event).to.be.instanceOf(DeviceMopAttachedDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.isAttached).to.be.equal(true); + }); + + it('should not update the device hasMopAttached when value is equal', function () { + const device = new Device({ ...givenSomeDeviceProps(), hasMopAttached: true }); + + device.updateHasMopAttached(true); + + expect(device.hasMopAttached).to.be.equal(true); + expect(device.domainEvents[1]).to.not.exist; + }); + }); + + describe('#updateHasWaitingMap()', function () { + it('should update the device hasWaitingMap', function () { + const device = new Device({ ...givenSomeDeviceProps(), hasWaitingMap: false }); + + device.updateHasWaitingMap(true); + + expect(device.hasWaitingMap).to.be.equal(true); + + const event = device.domainEvents[1] as DeviceMapPendingDomainEvent; + + expect(event).to.be.instanceOf(DeviceMapPendingDomainEvent); + expect(event.aggregateId).to.equal(device.id); + expect(event.isPending).to.be.equal(true); + }); + + it('should not update the device hasWaitingMap when value is equal', function () { + const device = new Device({ ...givenSomeDeviceProps(), hasWaitingMap: true }); + + device.updateHasWaitingMap(true); + + expect(device.hasWaitingMap).to.be.equal(true); + expect(device.domainEvents[1]).to.not.exist; + }); + }); +}); diff --git a/packages/domain/src/aggregate-roots/device.aggregate-root.ts b/packages/domain/src/aggregate-roots/device.aggregate-root.ts new file mode 100644 index 00000000..66621cb3 --- /dev/null +++ b/packages/domain/src/aggregate-roots/device.aggregate-root.ts @@ -0,0 +1,493 @@ +import { AggregateRoot, ID, symmetricDifference } from '@agnoc/toolkit'; +import { DeviceBatteryChangedDomainEvent } from '../domain-events/device-battery-changed.domain-event'; +import { DeviceCleanWorkChangedDomainEvent } from '../domain-events/device-clean-work-changed.domain-event'; +import { DeviceConnectedDomainEvent } from '../domain-events/device-connected.domain-event'; +import { DeviceConsumablesChangedDomainEvent } from '../domain-events/device-consumables-changed.domain-event'; +import { DeviceCreatedDomainEvent } from '../domain-events/device-created.domain-event'; +import { DeviceErrorChangedDomainEvent } from '../domain-events/device-error-changed.domain-event'; +import { DeviceFanSpeedChangedDomainEvent } from '../domain-events/device-fan-speed-changed.domain-event'; +import { DeviceLockedDomainEvent } from '../domain-events/device-locked.domain-event'; +import { DeviceMapChangedDomainEvent } from '../domain-events/device-map-changed.domain-event'; +import { DeviceMapPendingDomainEvent } from '../domain-events/device-map-pending.domain-event'; +import { DeviceModeChangedDomainEvent } from '../domain-events/device-mode-changed.domain-event'; +import { DeviceMopAttachedDomainEvent } from '../domain-events/device-mop-attached.domain-event'; +import { DeviceNetworkChangedDomainEvent } from '../domain-events/device-network-changed.domain-event'; +import { DeviceOrdersChangedDomainEvent } from '../domain-events/device-orders-changed.domain-event'; +import { DeviceSettingsChangedDomainEvent } from '../domain-events/device-settings-changed.domain-event'; +import { DeviceStateChangedDomainEvent } from '../domain-events/device-state-changed.domain-event'; +import { DeviceVersionChangedDomainEvent } from '../domain-events/device-version-changed.domain-event'; +import { DeviceWaterLevelChangedDomainEvent } from '../domain-events/device-water-level-changed.domain-event'; +import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; +import { DeviceError } from '../domain-primitives/device-error.domain-primitive'; +import { DeviceFanSpeed } from '../domain-primitives/device-fan-speed.domain-primitive'; +import { DeviceMode } from '../domain-primitives/device-mode.domain-primitive'; +import { DeviceState } from '../domain-primitives/device-state.domain-primitive'; +import { DeviceWaterLevel } from '../domain-primitives/device-water-level.domain-primitive'; +import { DeviceMap } from '../entities/device-map.entity'; +import { DeviceOrder } from '../entities/device-order.entity'; +import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; +import { DeviceNetwork } from '../value-objects/device-network.value-object'; +import { DeviceSettings } from '../value-objects/device-settings.value-object'; +import { DeviceSystem } from '../value-objects/device-system.value-object'; +import { DeviceVersion } from '../value-objects/device-version.value-object'; +import type { EntityProps } from '@agnoc/toolkit'; + +/** Describes the properties of a device. */ +export interface DeviceProps extends EntityProps { + /** The user id. */ + userId: ID; + /** The device system. */ + system: DeviceSystem; + /** The device version. */ + version: DeviceVersion; + /** The device battery. */ + battery: DeviceBattery; + /** Whether the device is connected. */ + isConnected: boolean; + /** Whether the device is locked. */ + isLocked: boolean; + /** The device settings. */ + settings?: DeviceSettings; + /** The device current clean. */ + currentCleanWork?: DeviceCleanWork; + /** The device orders. */ + orders?: DeviceOrder[]; + /** The device consumables. */ + consumables?: DeviceConsumable[]; + /** The device map. */ + map?: DeviceMap; + /** The device network. */ + network?: DeviceNetwork; + /** The device state. */ + state?: DeviceState; + /** The device mode. */ + mode?: DeviceMode; + /** The device error. */ + error?: DeviceError; + /** The device fan speed. */ + fanSpeed?: DeviceFanSpeed; + /** The device water level. */ + waterLevel?: DeviceWaterLevel; + /** whether the device has a mop attached. */ + hasMopAttached?: boolean; + /** Whether the device has a waiting map. */ + hasWaitingMap?: boolean; +} + +/** Describes a device. */ +export class Device extends AggregateRoot { + constructor(props: DeviceProps) { + super(props); + this.addEvent(new DeviceCreatedDomainEvent({ aggregateId: this.id })); + } + + /** Returns the user id. */ + get userId(): ID { + return this.props.userId; + } + + /** Returns the device system. */ + get system(): DeviceSystem { + return this.props.system; + } + + /** Returns the device battery. */ + get battery(): DeviceBattery { + return this.props.battery; + } + + /** Returns whether the device is connected. */ + get isConnected(): boolean { + return this.props.isConnected; + } + + /** Returns whether the device is locked. */ + get isLocked(): boolean { + return this.props.isLocked; + } + + /** Returns the device version. */ + get version(): DeviceVersion { + return this.props.version; + } + + /** Returns the device settings. */ + get settings(): DeviceSettings | undefined { + return this.props.settings; + } + + /** Returns the device current clean. */ + get currentCleanWork(): DeviceCleanWork | undefined { + return this.props.currentCleanWork; + } + + /** Returns the device orders. */ + get orders(): DeviceOrder[] | undefined { + return this.props.orders; + } + + /** Returns the device consumables. */ + get consumables(): DeviceConsumable[] | undefined { + return this.props.consumables; + } + + /** Returns the device map. */ + get map(): DeviceMap | undefined { + return this.props.map; + } + + /** Returns the device network. */ + get network(): DeviceNetwork | undefined { + return this.props.network; + } + + /** Returns the device state. */ + get state(): DeviceState | undefined { + return this.props.state; + } + + /** Returns the device mode. */ + get mode(): DeviceMode | undefined { + return this.props.mode; + } + + /** Returns the device error. */ + get error(): DeviceError | undefined { + return this.props.error; + } + + /** Returns the device fan speed. */ + get fanSpeed(): DeviceFanSpeed | undefined { + return this.props.fanSpeed; + } + + /** Returns the device water level. */ + get waterLevel(): DeviceWaterLevel | undefined { + return this.props.waterLevel; + } + + /** Returns whether the device has a mop attached. */ + get hasMopAttached(): boolean { + return Boolean(this.props.hasMopAttached); + } + + /** Returns whether the device has a waiting map. */ + get hasWaitingMap(): boolean { + return Boolean(this.props.hasWaitingMap); + } + + /** Sets the device as connected. */ + setAsConnected(): void { + if (this.isConnected) { + return; + } + + this.addEvent(new DeviceConnectedDomainEvent({ aggregateId: this.id })); + this.props.isConnected = true; + } + + /** Sets the device as connected. */ + setAsLocked(): void { + if (this.isLocked) { + return; + } + + this.addEvent(new DeviceLockedDomainEvent({ aggregateId: this.id })); + this.props.isLocked = true; + } + + /** Updates the device version. */ + updateVersion(version: DeviceVersion): void { + if (version.equals(this.version)) { + return; + } + + this.validateDefinedProp({ version }, 'version'); + this.validateInstanceProp({ version }, 'version', DeviceVersion); + this.addEvent( + new DeviceVersionChangedDomainEvent({ + aggregateId: this.id, + previousVersion: this.version, + currentVersion: version, + }), + ); + this.props.version = version; + } + + /** Updates the device settings. */ + updateSettings(settings: DeviceSettings): void { + if (settings.equals(this.settings)) { + return; + } + + this.validateDefinedProp({ settings }, 'settings'); + this.validateInstanceProp({ settings }, 'settings', DeviceSettings); + this.addEvent( + new DeviceSettingsChangedDomainEvent({ + aggregateId: this.id, + previousSettings: this.settings, + currentSettings: settings, + }), + ); + this.props.settings = settings; + } + + /** Updates the device current clean. */ + updateCurrentCleanWork(currentCleanWork: DeviceCleanWork): void { + if (currentCleanWork.equals(this.currentCleanWork)) { + return; + } + + this.validateDefinedProp({ currentCleanWork }, 'currentCleanWork'); + this.validateInstanceProp({ currentCleanWork }, 'currentCleanWork', DeviceCleanWork); + this.addEvent( + new DeviceCleanWorkChangedDomainEvent({ + aggregateId: this.id, + previousCleanWork: this.currentCleanWork, + currentCleanWork, + }), + ); + this.props.currentCleanWork = currentCleanWork; + } + + /** Updates the device orders. */ + updateOrders(orders: DeviceOrder[]): void { + const previousOrderIds = this.orders?.map((order) => order.id.value) ?? []; + const currentOrderIds = orders.map((order) => order.id.value); + const diff = symmetricDifference(previousOrderIds, currentOrderIds); + + if (diff.length === 0) { + return; + } + + this.validateDefinedProp({ orders }, 'orders'); + this.validateArrayProp({ orders }, 'orders', DeviceOrder); + this.addEvent( + new DeviceOrdersChangedDomainEvent({ + aggregateId: this.id, + previousOrders: this.orders, + currentOrders: orders, + }), + ); + this.props.orders = orders; + } + + /** Updates the device consumables. */ + updateConsumables(consumables: DeviceConsumable[]): void { + // TODO: should we check if the consumables are changed? + this.validateDefinedProp({ consumables }, 'consumables'); + this.validateArrayProp({ consumables }, 'consumables', DeviceConsumable); + this.addEvent( + new DeviceConsumablesChangedDomainEvent({ + aggregateId: this.id, + previousConsumables: this.consumables, + currentConsumables: consumables, + }), + ); + this.props.consumables = consumables; + } + + /** Updates the device map. */ + updateMap(map: DeviceMap): void { + // TODO: prevent map changed event if the map is not changed + this.validateDefinedProp({ map }, 'map'); + this.validateInstanceProp({ map }, 'map', DeviceMap); + this.addEvent( + new DeviceMapChangedDomainEvent({ + aggregateId: this.id, + previousMap: this.map, + currentMap: map, + }), + ); + this.props.map = map; + } + + /** Updates the device network. */ + updateNetwork(network: DeviceNetwork): void { + if (network.equals(this.network)) { + return; + } + + this.validateDefinedProp({ network }, 'network'); + this.validateInstanceProp({ network }, 'network', DeviceNetwork); + this.addEvent( + new DeviceNetworkChangedDomainEvent({ + aggregateId: this.id, + previousNetwork: this.props.network, + currentNetwork: network, + }), + ); + this.props.network = network; + } + + /** Updates the device battery. */ + updateBattery(battery: DeviceBattery): void { + if (battery.equals(this.battery)) { + return; + } + + this.validateDefinedProp({ battery }, 'battery'); + this.validateInstanceProp({ battery }, 'battery', DeviceBattery); + this.addEvent( + new DeviceBatteryChangedDomainEvent({ + aggregateId: this.id, + previousBattery: this.props.battery, + currentBattery: battery, + }), + ); + this.props.battery = battery; + } + + /** Updates the device state. */ + updateState(state: DeviceState): void { + if (state.equals(this.state)) { + return; + } + + this.validateDefinedProp({ state }, 'state'); + this.validateInstanceProp({ state }, 'state', DeviceState); + this.addEvent( + new DeviceStateChangedDomainEvent({ + aggregateId: this.id, + previousState: this.state, + currentState: state, + }), + ); + this.props.state = state; + } + + /** Updates the device mode. */ + updateMode(mode: DeviceMode): void { + if (mode.equals(this.mode)) { + return; + } + + this.validateDefinedProp({ mode }, 'mode'); + this.validateInstanceProp({ mode }, 'mode', DeviceMode); + this.addEvent( + new DeviceModeChangedDomainEvent({ + aggregateId: this.id, + previousMode: this.mode, + currentMode: mode, + }), + ); + this.props.mode = mode; + } + + /** Updates the device error. */ + updateError(error: DeviceError): void { + if (error.equals(this.error)) { + return; + } + + this.validateDefinedProp({ error }, 'error'); + this.validateInstanceProp({ error }, 'error', DeviceError); + this.addEvent( + new DeviceErrorChangedDomainEvent({ + aggregateId: this.id, + previousError: this.error, + currentError: error, + }), + ); + this.props.error = error; + } + + /** Updates the device fan speed. */ + updateFanSpeed(fanSpeed: DeviceFanSpeed): void { + if (fanSpeed.equals(this.fanSpeed)) { + return; + } + + this.validateDefinedProp({ fanSpeed }, 'fanSpeed'); + this.validateInstanceProp({ fanSpeed }, 'fanSpeed', DeviceFanSpeed); + this.addEvent( + new DeviceFanSpeedChangedDomainEvent({ + aggregateId: this.id, + previousFanSpeed: this.fanSpeed, + currentFanSpeed: fanSpeed, + }), + ); + this.props.fanSpeed = fanSpeed; + } + + /** Updates the device water level. */ + updateWaterLevel(waterLevel: DeviceWaterLevel): void { + if (waterLevel.equals(this.waterLevel)) { + return; + } + + this.validateDefinedProp({ waterLevel }, 'waterLevel'); + this.validateInstanceProp({ waterLevel }, 'waterLevel', DeviceWaterLevel); + this.addEvent( + new DeviceWaterLevelChangedDomainEvent({ + aggregateId: this.id, + previousWaterLevel: this.waterLevel, + currentWaterLevel: waterLevel, + }), + ); + this.props.waterLevel = waterLevel; + } + + /** Updates whether the device has a mop attached. */ + updateHasMopAttached(hasMopAttached: boolean): void { + if (hasMopAttached === this.hasMopAttached) { + return; + } + + this.validateDefinedProp({ hasMopAttached }, 'hasMopAttached'); + this.validateTypeProp({ hasMopAttached }, 'hasMopAttached', 'boolean'); + this.addEvent( + new DeviceMopAttachedDomainEvent({ + aggregateId: this.id, + isAttached: hasMopAttached, + }), + ); + this.props.hasMopAttached = hasMopAttached; + } + + /** Updates whether the device has a waiting map. */ + updateHasWaitingMap(hasWaitingMap: boolean): void { + if (hasWaitingMap === this.hasWaitingMap) { + return; + } + + this.validateDefinedProp({ hasWaitingMap }, 'hasWaitingMap'); + this.validateTypeProp({ hasWaitingMap }, 'hasWaitingMap', 'boolean'); + this.addEvent( + new DeviceMapPendingDomainEvent({ + aggregateId: this.id, + isPending: hasWaitingMap, + }), + ); + this.props.hasWaitingMap = hasWaitingMap; + } + + protected validate(props: DeviceProps): void { + const keys: (keyof DeviceProps)[] = ['userId', 'system', 'version', 'battery', 'isConnected', 'isLocked']; + + keys.forEach((prop) => { + this.validateDefinedProp(props, prop); + }); + + this.validateInstanceProp(props, 'userId', ID); + this.validateInstanceProp(props, 'system', DeviceSystem); + this.validateInstanceProp(props, 'version', DeviceVersion); + this.validateTypeProp(props, 'isConnected', 'boolean'); + this.validateTypeProp(props, 'isLocked', 'boolean'); + this.validateInstanceProp(props, 'settings', DeviceSettings); + this.validateInstanceProp(props, 'currentCleanWork', DeviceCleanWork); + this.validateArrayProp(props, 'orders', DeviceOrder); + this.validateArrayProp(props, 'consumables', DeviceConsumable); + this.validateInstanceProp(props, 'map', DeviceMap); + this.validateInstanceProp(props, 'network', DeviceNetwork); + this.validateInstanceProp(props, 'battery', DeviceBattery); + this.validateInstanceProp(props, 'state', DeviceState); + this.validateInstanceProp(props, 'mode', DeviceMode); + this.validateInstanceProp(props, 'error', DeviceError); + this.validateInstanceProp(props, 'fanSpeed', DeviceFanSpeed); + this.validateInstanceProp(props, 'waterLevel', DeviceWaterLevel); + this.validateTypeProp(props, 'hasMopAttached', 'boolean'); + this.validateTypeProp(props, 'hasWaitingMap', 'boolean'); + } +} diff --git a/packages/domain/src/commands/clean-spot.command.test.ts b/packages/domain/src/commands/clean-spot.command.test.ts new file mode 100644 index 00000000..db2a382c --- /dev/null +++ b/packages/domain/src/commands/clean-spot.command.test.ts @@ -0,0 +1,53 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeMapCoordinateProps } from '../test-support'; +import { MapCoordinate } from '../value-objects/map-coordinate.value-object'; +import { CleanSpotCommand } from './clean-spot.command'; +import type { CleanSpotCommandInput } from './clean-spot.command'; + +describe('CleanSpotCommand', function () { + it('should be created', function () { + const input = givenACleanSpotCommandInput(); + const command = new CleanSpotCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.spot).to.be.equal(input.spot); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new CleanSpotCommand({ ...givenACleanSpotCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for CleanSpotCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new CleanSpotCommand({ ...givenACleanSpotCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of CleanSpotCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'spot' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new CleanSpotCommand({ ...givenACleanSpotCommandInput(), spot: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'spot' for CleanSpotCommand not provided`, + ); + }); + + it("should throw an error when 'spot' is not an MapCoordinate", function () { + // @ts-expect-error - invalid property + expect(() => new CleanSpotCommand({ ...givenACleanSpotCommandInput(), spot: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'spot' of CleanSpotCommand is not an instance of MapCoordinate`, + ); + }); +}); + +function givenACleanSpotCommandInput(): CleanSpotCommandInput { + return { deviceId: ID.generate(), spot: new MapCoordinate(givenSomeMapCoordinateProps()) }; +} diff --git a/packages/domain/src/commands/clean-spot.command.ts b/packages/domain/src/commands/clean-spot.command.ts new file mode 100644 index 00000000..6d24623b --- /dev/null +++ b/packages/domain/src/commands/clean-spot.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { MapCoordinate } from '../value-objects/map-coordinate.value-object'; + +/** Input for the command cleaning a spot. */ +export interface CleanSpotCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Coordinate of the spot to clean. */ + spot: MapCoordinate; +} + +/** Command for starting the cleaning process of a device. */ +export class CleanSpotCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the position of the spot to clean. */ + get spot(): MapCoordinate { + return this.props.spot; + } + + protected validate(props: CleanSpotCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'spot'); + this.validateInstanceProp(props, 'spot', MapCoordinate); + } +} diff --git a/packages/domain/src/commands/clean-zones.command.test.ts b/packages/domain/src/commands/clean-zones.command.test.ts new file mode 100644 index 00000000..3ae0cc8e --- /dev/null +++ b/packages/domain/src/commands/clean-zones.command.test.ts @@ -0,0 +1,61 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { Zone } from '../entities/zone.entity'; +import { givenSomeZoneProps } from '../test-support'; +import { CleanZonesCommand } from './clean-zones.command'; +import type { CleanZonesCommandInput } from './clean-zones.command'; + +describe('CleanZonesCommand', function () { + it('should be created', function () { + const input = givenACleanZonesCommandInput(); + const command = new CleanZonesCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.zones).to.be.equal(input.zones); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new CleanZonesCommand({ ...givenACleanZonesCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for CleanZonesCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new CleanZonesCommand({ ...givenACleanZonesCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of CleanZonesCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'zones' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new CleanZonesCommand({ ...givenACleanZonesCommandInput(), zones: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'zones' for CleanZonesCommand not provided`, + ); + }); + + it("should throw an error when 'zones' is not an array", function () { + // @ts-expect-error - invalid property + expect(() => new CleanZonesCommand({ ...givenACleanZonesCommandInput(), zones: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'zones' of CleanZonesCommand is not an array`, + ); + }); + + it("should throw an error when 'zones' is not an array of Zone", function () { + // @ts-expect-error - invalid property + expect(() => new CleanZonesCommand({ ...givenACleanZonesCommandInput(), zones: ['foo'] })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'zones' of CleanZonesCommand is not an array of Zone`, + ); + }); +}); + +function givenACleanZonesCommandInput(): CleanZonesCommandInput { + return { deviceId: ID.generate(), zones: [new Zone(givenSomeZoneProps())] }; +} diff --git a/packages/domain/src/commands/clean-zones.command.ts b/packages/domain/src/commands/clean-zones.command.ts new file mode 100644 index 00000000..6125267f --- /dev/null +++ b/packages/domain/src/commands/clean-zones.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { Zone } from '../entities/zone.entity'; + +/** Input for the command cleaning zones. */ +export interface CleanZonesCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Zones to clean. */ + zones: Zone[]; +} + +/** Command for starting the cleaning process of a device. */ +export class CleanZonesCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the zones to clean. */ + get zones(): Zone[] { + return this.props.zones; + } + + protected validate(props: CleanZonesCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'zones'); + this.validateArrayProp(props, 'zones', Zone); + } +} diff --git a/packages/domain/src/commands/commands.ts b/packages/domain/src/commands/commands.ts new file mode 100644 index 00000000..8cf3520a --- /dev/null +++ b/packages/domain/src/commands/commands.ts @@ -0,0 +1,39 @@ +import { CleanSpotCommand } from './clean-spot.command'; +import { CleanZonesCommand } from './clean-zones.command'; +import { LocateDeviceCommand } from './locate-device.command'; +import { PauseCleaningCommand } from './pause-cleaning.command'; +import { ResetConsumableCommand } from './reset-consumable.command'; +import { ReturnHomeCommand } from './return-home.command'; +import { SetCarpetModeCommand } from './set-carpet-mode.command'; +import { SetDeviceModeCommand } from './set-device-mode.command'; +import { SetDeviceQuietHoursCommand } from './set-device-quiet-hours.command'; +import { SetDeviceVoiceCommand } from './set-device-voice.command'; +import { SetFanSpeedCommand } from './set-fan-speed.command'; +import { SetWaterLevelCommand } from './set-water-level.command'; +import { StartCleaningCommand } from './start-cleaning.command'; +import { StopCleaningCommand } from './stop-cleaning.command'; +import type { InstanceTypeProps } from '@agnoc/toolkit'; + +/** Commands that can be executed. */ +export const Commands = { + CleanSpotCommand, + CleanZonesCommand, + LocateDeviceCommand, + PauseCleaningCommand, + ResetConsumableCommand, + ReturnHomeCommand, + SetCarpetModeCommand, + SetDeviceModeCommand, + SetDeviceQuietHoursCommand, + SetDeviceVoiceCommand, + SetFanSpeedCommand, + SetWaterLevelCommand, + StartCleaningCommand, + StopCleaningCommand, +} as const; + +/** Commands that can be executed. */ +export type Commands = InstanceTypeProps; + +/** Names of commands that can be executed. */ +export type CommandNames = keyof typeof Commands; diff --git a/packages/domain/src/commands/locate-device.command.test.ts b/packages/domain/src/commands/locate-device.command.test.ts new file mode 100644 index 00000000..64216581 --- /dev/null +++ b/packages/domain/src/commands/locate-device.command.test.ts @@ -0,0 +1,34 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { LocateDeviceCommand } from './locate-device.command'; +import type { LocateDeviceCommandInput } from './locate-device.command'; + +describe('LocateDeviceCommand', function () { + it('should be created', function () { + const input = givenALocateDeviceCommandInput(); + const command = new LocateDeviceCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new LocateDeviceCommand({ ...givenALocateDeviceCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for LocateDeviceCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new LocateDeviceCommand({ ...givenALocateDeviceCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of LocateDeviceCommand is not an instance of ID`, + ); + }); +}); + +function givenALocateDeviceCommandInput(): LocateDeviceCommandInput { + return { deviceId: ID.generate() }; +} diff --git a/packages/domain/src/commands/locate-device.command.ts b/packages/domain/src/commands/locate-device.command.ts new file mode 100644 index 00000000..8c3e9183 --- /dev/null +++ b/packages/domain/src/commands/locate-device.command.ts @@ -0,0 +1,20 @@ +import { Command, ID } from '@agnoc/toolkit'; + +/** Input for the command locating a device. */ +export interface LocateDeviceCommandInput { + /** ID of the device to locate. */ + deviceId: ID; +} + +/** Command that locates a device. */ +export class LocateDeviceCommand extends Command { + /** Returns the ID of the device to locate. */ + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: LocateDeviceCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } +} diff --git a/packages/domain/src/commands/pause-cleaning.command.test.ts b/packages/domain/src/commands/pause-cleaning.command.test.ts new file mode 100644 index 00000000..2f5bcd19 --- /dev/null +++ b/packages/domain/src/commands/pause-cleaning.command.test.ts @@ -0,0 +1,34 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { PauseCleaningCommand } from './pause-cleaning.command'; +import type { PauseCleaningCommandInput } from './pause-cleaning.command'; + +describe('PauseCleaningCommand', function () { + it('should be created', function () { + const input = givenAPauseCleaningCommandInput(); + const command = new PauseCleaningCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new PauseCleaningCommand({ ...givenAPauseCleaningCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for PauseCleaningCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new PauseCleaningCommand({ ...givenAPauseCleaningCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of PauseCleaningCommand is not an instance of ID`, + ); + }); +}); + +function givenAPauseCleaningCommandInput(): PauseCleaningCommandInput { + return { deviceId: ID.generate() }; +} diff --git a/packages/domain/src/commands/pause-cleaning.command.ts b/packages/domain/src/commands/pause-cleaning.command.ts new file mode 100644 index 00000000..8256059b --- /dev/null +++ b/packages/domain/src/commands/pause-cleaning.command.ts @@ -0,0 +1,20 @@ +import { Command, ID } from '@agnoc/toolkit'; + +/** Input for the command pausing the cleaning process of a device. */ +export interface PauseCleaningCommandInput { + /** ID of the device. */ + deviceId: ID; +} + +/** Command for pausing the cleaning process of a device. */ +export class PauseCleaningCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: PauseCleaningCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } +} diff --git a/packages/domain/src/commands/reset-consumable.command.test.ts b/packages/domain/src/commands/reset-consumable.command.test.ts new file mode 100644 index 00000000..a752cf30 --- /dev/null +++ b/packages/domain/src/commands/reset-consumable.command.test.ts @@ -0,0 +1,54 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceConsumable, DeviceConsumableType } from '../value-objects/device-consumable.value-object'; +import { ResetConsumableCommand } from './reset-consumable.command'; +import type { ResetConsumableCommandInput } from './reset-consumable.command'; + +describe('ResetConsumableCommand', function () { + it('should be created', function () { + const input = givenAResetConsumableCommandInput(); + const command = new ResetConsumableCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.consumable).to.be.equal(input.consumable); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new ResetConsumableCommand({ ...givenAResetConsumableCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for ResetConsumableCommand not provided`, + ); + }); + + it("should throw an error when 'consumable' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new ResetConsumableCommand({ ...givenAResetConsumableCommandInput(), consumable: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'consumable' for ResetConsumableCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new ResetConsumableCommand({ ...givenAResetConsumableCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of ResetConsumableCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'consumable' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new ResetConsumableCommand({ ...givenAResetConsumableCommandInput(), consumable: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'consumable' of ResetConsumableCommand is not an instance of DeviceConsumable`, + ); + }); +}); + +function givenAResetConsumableCommandInput(): ResetConsumableCommandInput { + return { + deviceId: ID.generate(), + consumable: new DeviceConsumable({ type: DeviceConsumableType.MainBrush, hoursUsed: 5 }), + }; +} diff --git a/packages/domain/src/commands/reset-consumable.command.ts b/packages/domain/src/commands/reset-consumable.command.ts new file mode 100644 index 00000000..d1ed4c50 --- /dev/null +++ b/packages/domain/src/commands/reset-consumable.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; + +/** Input for the command resetting a consumable. */ +export interface ResetConsumableCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Consumable to reset. */ + consumable: DeviceConsumable; +} + +/** Command that locates a device. */ +export class ResetConsumableCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the consumable to reset. */ + get consumable(): DeviceConsumable { + return this.props.consumable; + } + + protected validate(props: ResetConsumableCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'consumable'); + this.validateInstanceProp(props, 'consumable', DeviceConsumable); + } +} diff --git a/packages/domain/src/commands/return-home.command.test.ts b/packages/domain/src/commands/return-home.command.test.ts new file mode 100644 index 00000000..7e8d5a2c --- /dev/null +++ b/packages/domain/src/commands/return-home.command.test.ts @@ -0,0 +1,34 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { ReturnHomeCommand } from './return-home.command'; +import type { ReturnHomeCommandInput } from './return-home.command'; + +describe('ReturnHomeCommand', function () { + it('should be created', function () { + const input = givenAReturnHomeCommandInput(); + const command = new ReturnHomeCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new ReturnHomeCommand({ ...givenAReturnHomeCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for ReturnHomeCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new ReturnHomeCommand({ ...givenAReturnHomeCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of ReturnHomeCommand is not an instance of ID`, + ); + }); +}); + +function givenAReturnHomeCommandInput(): ReturnHomeCommandInput { + return { deviceId: ID.generate() }; +} diff --git a/packages/domain/src/commands/return-home.command.ts b/packages/domain/src/commands/return-home.command.ts new file mode 100644 index 00000000..20e253ab --- /dev/null +++ b/packages/domain/src/commands/return-home.command.ts @@ -0,0 +1,20 @@ +import { Command, ID } from '@agnoc/toolkit'; + +/** Input for the command returning a device to its home position. */ +export interface ReturnHomeCommandInput { + /** ID of the device. */ + deviceId: ID; +} + +/** Command for returning a device to its home position. */ +export class ReturnHomeCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: ReturnHomeCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } +} diff --git a/packages/domain/src/commands/set-carpet-mode.command.test.ts b/packages/domain/src/commands/set-carpet-mode.command.test.ts new file mode 100644 index 00000000..8eef5543 --- /dev/null +++ b/packages/domain/src/commands/set-carpet-mode.command.test.ts @@ -0,0 +1,54 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceSetting } from '../value-objects/device-setting.value-object'; +import { SetCarpetModeCommand } from './set-carpet-mode.command'; +import type { SetCarpetModeCommandInput } from './set-carpet-mode.command'; + +describe('SetCarpetModeCommand', function () { + it('should be created', function () { + const input = givenASetCarpetModeCommandInput(); + const command = new SetCarpetModeCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.carpetMode).to.be.equal(input.carpetMode); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetCarpetModeCommand({ ...givenASetCarpetModeCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetCarpetModeCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetCarpetModeCommand({ ...givenASetCarpetModeCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetCarpetModeCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'carpetMode' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetCarpetModeCommand({ ...givenASetCarpetModeCommandInput(), carpetMode: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'carpetMode' for SetCarpetModeCommand not provided`); + }); + + it("should throw an error when 'carpetMode' is not an VoiceSetting", function () { + expect( + // @ts-expect-error - invalid property + () => new SetCarpetModeCommand({ ...givenASetCarpetModeCommandInput(), carpetMode: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'carpetMode' of SetCarpetModeCommand is not an instance of DeviceSetting`, + ); + }); +}); + +function givenASetCarpetModeCommandInput(): SetCarpetModeCommandInput { + return { deviceId: ID.generate(), carpetMode: new DeviceSetting({ isEnabled: true }) }; +} diff --git a/packages/domain/src/commands/set-carpet-mode.command.ts b/packages/domain/src/commands/set-carpet-mode.command.ts new file mode 100644 index 00000000..05af5aeb --- /dev/null +++ b/packages/domain/src/commands/set-carpet-mode.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { DeviceSetting } from '../value-objects/device-setting.value-object'; + +/** Input for the command stopping the cleaning process of a device. */ +export interface SetCarpetModeCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Carpet mode setting. */ + carpetMode: DeviceSetting; +} + +/** Command for stopping the cleaning process of a device. */ +export class SetCarpetModeCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the carpet mode setting. */ + get carpetMode(): DeviceSetting { + return this.props.carpetMode; + } + + protected validate(props: SetCarpetModeCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'carpetMode'); + this.validateInstanceProp(props, 'carpetMode', DeviceSetting); + } +} diff --git a/packages/domain/src/commands/set-device-mode.command.test.ts b/packages/domain/src/commands/set-device-mode.command.test.ts new file mode 100644 index 00000000..81397268 --- /dev/null +++ b/packages/domain/src/commands/set-device-mode.command.test.ts @@ -0,0 +1,54 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceMode, DeviceModeValue } from '../domain-primitives/device-mode.domain-primitive'; +import { SetDeviceModeCommand } from './set-device-mode.command'; +import type { SetDeviceModeCommandInput } from './set-device-mode.command'; + +describe('SetDeviceModeCommand', function () { + it('should be created', function () { + const input = givenASetDeviceModeCommandInput(); + const command = new SetDeviceModeCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.mode).to.be.equal(input.mode); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceModeCommand({ ...givenASetDeviceModeCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetDeviceModeCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceModeCommand({ ...givenASetDeviceModeCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetDeviceModeCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'mode' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceModeCommand({ ...givenASetDeviceModeCommandInput(), mode: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'mode' for SetDeviceModeCommand not provided`); + }); + + it("should throw an error when 'mode' is not an DeviceMode", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceModeCommand({ ...givenASetDeviceModeCommandInput(), mode: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'mode' of SetDeviceModeCommand is not an instance of DeviceMode`, + ); + }); +}); + +function givenASetDeviceModeCommandInput(): SetDeviceModeCommandInput { + return { deviceId: ID.generate(), mode: new DeviceMode(DeviceModeValue.None) }; +} diff --git a/packages/domain/src/commands/set-device-mode.command.ts b/packages/domain/src/commands/set-device-mode.command.ts new file mode 100644 index 00000000..68264fb9 --- /dev/null +++ b/packages/domain/src/commands/set-device-mode.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { DeviceMode } from '../domain-primitives/device-mode.domain-primitive'; + +/** Input for the command setting the mode of a device. */ +export interface SetDeviceModeCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Device mode setting. */ + mode: DeviceMode; +} + +/** Command for stopping the cleaning process of a device. */ +export class SetDeviceModeCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the device mode setting. */ + get mode(): DeviceMode { + return this.props.mode; + } + + protected validate(props: SetDeviceModeCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'mode'); + this.validateInstanceProp(props, 'mode', DeviceMode); + } +} diff --git a/packages/domain/src/commands/set-device-quiet-hours.command.test.ts b/packages/domain/src/commands/set-device-quiet-hours.command.test.ts new file mode 100644 index 00000000..949b3e7e --- /dev/null +++ b/packages/domain/src/commands/set-device-quiet-hours.command.test.ts @@ -0,0 +1,55 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeQuietHoursSettingProps } from '../test-support'; +import { QuietHoursSetting } from '../value-objects/quiet-hours-setting.value-object'; +import { SetDeviceQuietHoursCommand } from './set-device-quiet-hours.command'; +import type { SetDeviceQuietHoursCommandInput } from './set-device-quiet-hours.command'; + +describe('SetDeviceQuietHoursCommand', function () { + it('should be created', function () { + const input = givenASetDeviceQuietHoursCommandInput(); + const command = new SetDeviceQuietHoursCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.quietHours).to.be.equal(input.quietHours); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceQuietHoursCommand({ ...givenASetDeviceQuietHoursCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetDeviceQuietHoursCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceQuietHoursCommand({ ...givenASetDeviceQuietHoursCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetDeviceQuietHoursCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'quietHours' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceQuietHoursCommand({ ...givenASetDeviceQuietHoursCommandInput(), quietHours: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'quietHours' for SetDeviceQuietHoursCommand not provided`); + }); + + it("should throw an error when 'quietHours' is not an QuietHoursSetting", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceQuietHoursCommand({ ...givenASetDeviceQuietHoursCommandInput(), quietHours: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'quietHours' of SetDeviceQuietHoursCommand is not an instance of QuietHoursSetting`, + ); + }); +}); + +function givenASetDeviceQuietHoursCommandInput(): SetDeviceQuietHoursCommandInput { + return { deviceId: ID.generate(), quietHours: new QuietHoursSetting(givenSomeQuietHoursSettingProps()) }; +} diff --git a/packages/domain/src/commands/set-device-quiet-hours.command.ts b/packages/domain/src/commands/set-device-quiet-hours.command.ts new file mode 100644 index 00000000..345937ff --- /dev/null +++ b/packages/domain/src/commands/set-device-quiet-hours.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { QuietHoursSetting } from '../value-objects/quiet-hours-setting.value-object'; + +/** Input for the command setting the quiet hours of a device. */ +export interface SetDeviceQuietHoursCommandInput { + /** ID of the device to set the quiet hours for. */ + deviceId: ID; + /** Quiet hours to set. */ + quietHours: QuietHoursSetting; +} + +/** Command that sets the quiet hours of a device. */ +export class SetDeviceQuietHoursCommand extends Command { + /** Returns the ID of the device to set the quiet hours for. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the quiet hours to set. */ + get quietHours(): QuietHoursSetting { + return this.props.quietHours; + } + + protected validate(props: SetDeviceQuietHoursCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'quietHours'); + this.validateInstanceProp(props, 'quietHours', QuietHoursSetting); + } +} diff --git a/packages/domain/src/commands/set-device-voice.command.test.ts b/packages/domain/src/commands/set-device-voice.command.test.ts new file mode 100644 index 00000000..9b0908a2 --- /dev/null +++ b/packages/domain/src/commands/set-device-voice.command.test.ts @@ -0,0 +1,55 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeVoiceSettingProps } from '../test-support'; +import { VoiceSetting } from '../value-objects/voice-setting.value-object'; +import { SetDeviceVoiceCommand } from './set-device-voice.command'; +import type { SetDeviceVoiceCommandInput } from './set-device-voice.command'; + +describe('SetDeviceVoiceCommand', function () { + it('should be created', function () { + const input = givenASetDeviceVoiceCommandInput(); + const command = new SetDeviceVoiceCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.voice).to.be.equal(input.voice); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceVoiceCommand({ ...givenASetDeviceVoiceCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetDeviceVoiceCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceVoiceCommand({ ...givenASetDeviceVoiceCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetDeviceVoiceCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'voice' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetDeviceVoiceCommand({ ...givenASetDeviceVoiceCommandInput(), voice: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'voice' for SetDeviceVoiceCommand not provided`); + }); + + it("should throw an error when 'voice' is not an VoiceSetting", function () { + expect( + // @ts-expect-error - invalid property + () => new SetDeviceVoiceCommand({ ...givenASetDeviceVoiceCommandInput(), voice: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'voice' of SetDeviceVoiceCommand is not an instance of VoiceSetting`, + ); + }); +}); + +function givenASetDeviceVoiceCommandInput(): SetDeviceVoiceCommandInput { + return { deviceId: ID.generate(), voice: new VoiceSetting(givenSomeVoiceSettingProps()) }; +} diff --git a/packages/domain/src/commands/set-device-voice.command.ts b/packages/domain/src/commands/set-device-voice.command.ts new file mode 100644 index 00000000..b91588a2 --- /dev/null +++ b/packages/domain/src/commands/set-device-voice.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { VoiceSetting } from '../value-objects/voice-setting.value-object'; + +/** Input for the command setting the voice setting of a device. */ +export interface SetDeviceVoiceCommandInput { + /** ID of the device to set the voice setting for. */ + deviceId: ID; + /** Voice setting to set. */ + voice: VoiceSetting; +} + +/** Command that sets the voice setting of a device. */ +export class SetDeviceVoiceCommand extends Command { + /** Returns the ID of the device to set the voice setting for. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the voice setting to set. */ + get voice(): VoiceSetting { + return this.props.voice; + } + + protected validate(props: SetDeviceVoiceCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'voice'); + this.validateInstanceProp(props, 'voice', VoiceSetting); + } +} diff --git a/packages/domain/src/commands/set-fan-speed.command.test.ts b/packages/domain/src/commands/set-fan-speed.command.test.ts new file mode 100644 index 00000000..dc7d8365 --- /dev/null +++ b/packages/domain/src/commands/set-fan-speed.command.test.ts @@ -0,0 +1,54 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceFanSpeed, DeviceFanSpeedValue } from '../domain-primitives/device-fan-speed.domain-primitive'; +import { SetFanSpeedCommand } from './set-fan-speed.command'; +import type { SetFanSpeedCommandInput } from './set-fan-speed.command'; + +describe('SetFanSpeedCommand', function () { + it('should be created', function () { + const input = givenASetFanSpeedCommandInput(); + const command = new SetFanSpeedCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.fanSpeed).to.be.equal(input.fanSpeed); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetFanSpeedCommand({ ...givenASetFanSpeedCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetFanSpeedCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetFanSpeedCommand({ ...givenASetFanSpeedCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetFanSpeedCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'fanSpeed' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetFanSpeedCommand({ ...givenASetFanSpeedCommandInput(), fanSpeed: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'fanSpeed' for SetFanSpeedCommand not provided`); + }); + + it("should throw an error when 'fanSpeed' is not an DeviceFanSpeed", function () { + expect( + // @ts-expect-error - invalid property + () => new SetFanSpeedCommand({ ...givenASetFanSpeedCommandInput(), fanSpeed: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'fanSpeed' of SetFanSpeedCommand is not an instance of DeviceFanSpeed`, + ); + }); +}); + +function givenASetFanSpeedCommandInput(): SetFanSpeedCommandInput { + return { deviceId: ID.generate(), fanSpeed: new DeviceFanSpeed(DeviceFanSpeedValue.Low) }; +} diff --git a/packages/domain/src/commands/set-fan-speed.command.ts b/packages/domain/src/commands/set-fan-speed.command.ts new file mode 100644 index 00000000..98178039 --- /dev/null +++ b/packages/domain/src/commands/set-fan-speed.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { DeviceFanSpeed } from '../domain-primitives/device-fan-speed.domain-primitive'; + +/** Input for the command stopping the cleaning process of a device. */ +export interface SetFanSpeedCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Fan speed setting. */ + fanSpeed: DeviceFanSpeed; +} + +/** Command for stopping the cleaning process of a device. */ +export class SetFanSpeedCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the fan speed. */ + get fanSpeed(): DeviceFanSpeed { + return this.props.fanSpeed; + } + + protected validate(props: SetFanSpeedCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'fanSpeed'); + this.validateInstanceProp(props, 'fanSpeed', DeviceFanSpeed); + } +} diff --git a/packages/domain/src/commands/set-water-level.command.test.ts b/packages/domain/src/commands/set-water-level.command.test.ts new file mode 100644 index 00000000..a5bc8474 --- /dev/null +++ b/packages/domain/src/commands/set-water-level.command.test.ts @@ -0,0 +1,54 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceWaterLevel, DeviceWaterLevelValue } from '../domain-primitives/device-water-level.domain-primitive'; +import { SetWaterLevelCommand } from './set-water-level.command'; +import type { SetWaterLevelCommandInput } from './set-water-level.command'; + +describe('SetWaterLevelCommand', function () { + it('should be created', function () { + const input = givenASetWaterLevelCommandInput(); + const command = new SetWaterLevelCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + expect(command.waterLevel).to.be.equal(input.waterLevel); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetWaterLevelCommand({ ...givenASetWaterLevelCommandInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for SetWaterLevelCommand not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new SetWaterLevelCommand({ ...givenASetWaterLevelCommandInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of SetWaterLevelCommand is not an instance of ID`, + ); + }); + + it("should throw an error when 'waterLevel' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new SetWaterLevelCommand({ ...givenASetWaterLevelCommandInput(), waterLevel: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'waterLevel' for SetWaterLevelCommand not provided`); + }); + + it("should throw an error when 'waterLevel' is not an DeviceWaterLevel", function () { + expect( + // @ts-expect-error - invalid property + () => new SetWaterLevelCommand({ ...givenASetWaterLevelCommandInput(), waterLevel: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'waterLevel' of SetWaterLevelCommand is not an instance of DeviceWaterLevel`, + ); + }); +}); + +function givenASetWaterLevelCommandInput(): SetWaterLevelCommandInput { + return { deviceId: ID.generate(), waterLevel: new DeviceWaterLevel(DeviceWaterLevelValue.Low) }; +} diff --git a/packages/domain/src/commands/set-water-level.command.ts b/packages/domain/src/commands/set-water-level.command.ts new file mode 100644 index 00000000..bf8d8b28 --- /dev/null +++ b/packages/domain/src/commands/set-water-level.command.ts @@ -0,0 +1,30 @@ +import { Command, ID } from '@agnoc/toolkit'; +import { DeviceWaterLevel } from '../domain-primitives/device-water-level.domain-primitive'; + +/** Input for the command stopping the cleaning process of a device. */ +export interface SetWaterLevelCommandInput { + /** ID of the device. */ + deviceId: ID; + /** Fan speed setting. */ + waterLevel: DeviceWaterLevel; +} + +/** Command for stopping the cleaning process of a device. */ +export class SetWaterLevelCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + /** Returns the fan speed. */ + get waterLevel(): DeviceWaterLevel { + return this.props.waterLevel; + } + + protected validate(props: SetWaterLevelCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + this.validateDefinedProp(props, 'waterLevel'); + this.validateInstanceProp(props, 'waterLevel', DeviceWaterLevel); + } +} diff --git a/packages/domain/src/commands/start-cleaning.command.test.ts b/packages/domain/src/commands/start-cleaning.command.test.ts new file mode 100644 index 00000000..b43c13a5 --- /dev/null +++ b/packages/domain/src/commands/start-cleaning.command.test.ts @@ -0,0 +1,34 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { StartCleaningCommand } from './start-cleaning.command'; +import type { StartCleaningCommandInput } from './start-cleaning.command'; + +describe('StartCleaningCommand', function () { + it('should be created', function () { + const input = givenAStartCleaningCommandInput(); + const command = new StartCleaningCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new StartCleaningCommand({ ...givenAStartCleaningCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for StartCleaningCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new StartCleaningCommand({ ...givenAStartCleaningCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of StartCleaningCommand is not an instance of ID`, + ); + }); +}); + +function givenAStartCleaningCommandInput(): StartCleaningCommandInput { + return { deviceId: ID.generate() }; +} diff --git a/packages/domain/src/commands/start-cleaning.command.ts b/packages/domain/src/commands/start-cleaning.command.ts new file mode 100644 index 00000000..3d5c995b --- /dev/null +++ b/packages/domain/src/commands/start-cleaning.command.ts @@ -0,0 +1,20 @@ +import { Command, ID } from '@agnoc/toolkit'; + +/** Input for the command starting the cleaning process of a device. */ +export interface StartCleaningCommandInput { + /** ID of the device. */ + deviceId: ID; +} + +/** Command for starting the cleaning process of a device. */ +export class StartCleaningCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: StartCleaningCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } +} diff --git a/packages/domain/src/commands/stop-cleaning.command.test.ts b/packages/domain/src/commands/stop-cleaning.command.test.ts new file mode 100644 index 00000000..2f63e741 --- /dev/null +++ b/packages/domain/src/commands/stop-cleaning.command.test.ts @@ -0,0 +1,34 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { StopCleaningCommand } from './stop-cleaning.command'; +import type { StopCleaningCommandInput } from './stop-cleaning.command'; + +describe('StopCleaningCommand', function () { + it('should be created', function () { + const input = givenAStopCleaningCommandInput(); + const command = new StopCleaningCommand(input); + + expect(command).to.be.instanceOf(Command); + expect(command.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new StopCleaningCommand({ ...givenAStopCleaningCommandInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for StopCleaningCommand not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new StopCleaningCommand({ ...givenAStopCleaningCommandInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of StopCleaningCommand is not an instance of ID`, + ); + }); +}); + +function givenAStopCleaningCommandInput(): StopCleaningCommandInput { + return { deviceId: ID.generate() }; +} diff --git a/packages/domain/src/commands/stop-cleaning.command.ts b/packages/domain/src/commands/stop-cleaning.command.ts new file mode 100644 index 00000000..e0d6c51f --- /dev/null +++ b/packages/domain/src/commands/stop-cleaning.command.ts @@ -0,0 +1,20 @@ +import { Command, ID } from '@agnoc/toolkit'; + +/** Input for the command stopping the cleaning process of a device. */ +export interface StopCleaningCommandInput { + /** ID of the device. */ + deviceId: ID; +} + +/** Command for stopping the cleaning process of a device. */ +export class StopCleaningCommand extends Command { + /** Returns the ID of the device. */ + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: StopCleaningCommandInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } +} diff --git a/packages/domain/src/domain-events/connection-device-changed.domain-event.test.ts b/packages/domain/src/domain-events/connection-device-changed.domain-event.test.ts new file mode 100644 index 00000000..f2c3eeae --- /dev/null +++ b/packages/domain/src/domain-events/connection-device-changed.domain-event.test.ts @@ -0,0 +1,51 @@ +import { ID, DomainEvent, ArgumentInvalidException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { ConnectionDeviceChangedDomainEvent } from './connection-device-changed.domain-event'; + +describe('ConnectionDeviceChangedDomainEvent', function () { + it('should be created', function () { + const props = { aggregateId: ID.generate() }; + const event = new ConnectionDeviceChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousDeviceId).to.be.undefined; + expect(event.currentDeviceId).to.be.undefined; + }); + + it('should be created with previousDeviceId', function () { + const props = { aggregateId: ID.generate(), previousDeviceId: ID.generate() }; + const event = new ConnectionDeviceChangedDomainEvent(props); + + expect(event.previousDeviceId).to.be.equal(props.previousDeviceId); + expect(event.currentDeviceId).to.be.undefined; + }); + + it('should be created with currentDeviceId', function () { + const props = { aggregateId: ID.generate(), currentDeviceId: ID.generate() }; + const event = new ConnectionDeviceChangedDomainEvent(props); + + expect(event.previousDeviceId).to.be.undefined; + expect(event.currentDeviceId).to.be.equal(props.currentDeviceId); + }); + + it("should thrown an error when 'previousDeviceId' is not an instance of ID", function () { + expect( + // @ts-expect-error - invalid property + () => new ConnectionDeviceChangedDomainEvent({ aggregateId: ID.generate(), previousDeviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousDeviceId' of ConnectionDeviceChangedDomainEvent is not an instance of ID", + ); + }); + + it("should thrown an error when 'currentDeviceId' is not an instance of ID", function () { + expect( + // @ts-expect-error - invalid property + () => new ConnectionDeviceChangedDomainEvent({ aggregateId: ID.generate(), currentDeviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentDeviceId' of ConnectionDeviceChangedDomainEvent is not an instance of ID", + ); + }); +}); diff --git a/packages/domain/src/domain-events/connection-device-changed.domain-event.ts b/packages/domain/src/domain-events/connection-device-changed.domain-event.ts new file mode 100644 index 00000000..cda68e96 --- /dev/null +++ b/packages/domain/src/domain-events/connection-device-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent, ID } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface ConnectionDeviceChangedDomainEventProps extends DomainEventProps { + previousDeviceId?: ID; + currentDeviceId?: ID; +} + +export class ConnectionDeviceChangedDomainEvent extends DomainEvent { + get previousDeviceId(): ID | undefined { + return this.props.previousDeviceId; + } + + get currentDeviceId(): ID | undefined { + return this.props.currentDeviceId; + } + + protected validate(props: ConnectionDeviceChangedDomainEventProps): void { + if (props.previousDeviceId) { + this.validateInstanceProp(props, 'previousDeviceId', ID); + } + + if (props.currentDeviceId) { + this.validateInstanceProp(props, 'currentDeviceId', ID); + } + } +} diff --git a/packages/domain/src/domain-events/device-battery-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-battery-changed.domain-event.test.ts new file mode 100644 index 00000000..4eeefde7 --- /dev/null +++ b/packages/domain/src/domain-events/device-battery-changed.domain-event.test.ts @@ -0,0 +1,81 @@ +import { DomainEvent, ID, ArgumentInvalidException, ArgumentNotProvidedException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; +import { DeviceBatteryChangedDomainEvent } from './device-battery-changed.domain-event'; +import type { DeviceBatteryChangedDomainEventProps } from './device-battery-changed.domain-event'; + +describe('DeviceBatteryChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceBatteryChangedDomainEventProps(); + const event = new DeviceBatteryChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousBattery).to.be.equal(props.previousBattery); + expect(event.currentBattery).to.be.equal(props.currentBattery); + }); + + it("should thrown an error when 'previousBattery' is not provided", function () { + expect( + () => + new DeviceBatteryChangedDomainEvent({ + ...givenSomeDeviceBatteryChangedDomainEventProps(), + // @ts-expect-error - missing property + previousBattery: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'previousBattery' for DeviceBatteryChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'currentBattery' is not provided", function () { + expect( + () => + new DeviceBatteryChangedDomainEvent({ + ...givenSomeDeviceBatteryChangedDomainEventProps(), + // @ts-expect-error - missing property + currentBattery: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentBattery' for DeviceBatteryChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousBattery' is not an instance of ID", function () { + expect( + () => + new DeviceBatteryChangedDomainEvent({ + ...givenSomeDeviceBatteryChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousBattery: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousBattery' of DeviceBatteryChangedDomainEvent is not an instance of DeviceBattery", + ); + }); + + it("should thrown an error when 'currentBattery' is not an instance of ID", function () { + expect( + () => + new DeviceBatteryChangedDomainEvent({ + ...givenSomeDeviceBatteryChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentBattery: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentBattery' of DeviceBatteryChangedDomainEvent is not an instance of DeviceBattery", + ); + }); +}); + +function givenSomeDeviceBatteryChangedDomainEventProps(): DeviceBatteryChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousBattery: new DeviceBattery(50), + currentBattery: new DeviceBattery(100), + }; +} diff --git a/packages/domain/src/domain-events/device-battery-changed.domain-event.ts b/packages/domain/src/domain-events/device-battery-changed.domain-event.ts new file mode 100644 index 00000000..2e9db42a --- /dev/null +++ b/packages/domain/src/domain-events/device-battery-changed.domain-event.ts @@ -0,0 +1,25 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceBatteryChangedDomainEventProps extends DomainEventProps { + previousBattery: DeviceBattery; + currentBattery: DeviceBattery; +} + +export class DeviceBatteryChangedDomainEvent extends DomainEvent { + get previousBattery(): DeviceBattery { + return this.props.previousBattery; + } + + get currentBattery(): DeviceBattery { + return this.props.currentBattery; + } + + protected validate(props: DeviceBatteryChangedDomainEventProps): void { + this.validateDefinedProp(props, 'previousBattery'); + this.validateInstanceProp(props, 'previousBattery', DeviceBattery); + this.validateDefinedProp(props, 'currentBattery'); + this.validateInstanceProp(props, 'currentBattery', DeviceBattery); + } +} diff --git a/packages/domain/src/domain-events/device-clean-work-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-clean-work-changed.domain-event.test.ts new file mode 100644 index 00000000..2dbe13b4 --- /dev/null +++ b/packages/domain/src/domain-events/device-clean-work-changed.domain-event.test.ts @@ -0,0 +1,78 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceCleanWorkProps } from '../test-support'; +import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; +import { DeviceCleanWorkChangedDomainEvent } from './device-clean-work-changed.domain-event'; +import type { DeviceCleanWorkChangedDomainEventProps } from './device-clean-work-changed.domain-event'; + +describe('DeviceCleanWorkChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceCleanWorkChangedDomainEventProps(); + const event = new DeviceCleanWorkChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousCleanWork).to.be.undefined; + expect(event.currentCleanWork).to.be.equal(props.currentCleanWork); + }); + + it("should be created with 'previousCleanWork'", function () { + const props = { + ...givenSomeDeviceCleanWorkChangedDomainEventProps(), + previousCleanWork: new DeviceCleanWork(givenSomeDeviceCleanWorkProps()), + }; + const event = new DeviceCleanWorkChangedDomainEvent(props); + + expect(event.previousCleanWork).to.be.equal(props.previousCleanWork); + }); + + it("should thrown an error when 'currentCleanWork' is not provided", function () { + expect( + () => + new DeviceCleanWorkChangedDomainEvent({ + ...givenSomeDeviceCleanWorkChangedDomainEventProps(), + // @ts-expect-error - missing property + currentCleanWork: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentCleanWork' for DeviceCleanWorkChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousCleanWork' is not an instance of DeviceCleanWork", function () { + expect( + () => + new DeviceCleanWorkChangedDomainEvent({ + ...givenSomeDeviceCleanWorkChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousCleanWork: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousCleanWork' of DeviceCleanWorkChangedDomainEvent is not an instance of DeviceCleanWork", + ); + }); + + it("should thrown an error when 'currentCleanWork' is not an instance of DeviceCleanWork", function () { + expect( + () => + new DeviceCleanWorkChangedDomainEvent({ + ...givenSomeDeviceCleanWorkChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentCleanWork: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentCleanWork' of DeviceCleanWorkChangedDomainEvent is not an instance of DeviceCleanWork", + ); + }); +}); + +function givenSomeDeviceCleanWorkChangedDomainEventProps(): DeviceCleanWorkChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousCleanWork: undefined, + currentCleanWork: new DeviceCleanWork(givenSomeDeviceCleanWorkProps()), + }; +} diff --git a/packages/domain/src/domain-events/device-clean-work-changed.domain-event.ts b/packages/domain/src/domain-events/device-clean-work-changed.domain-event.ts new file mode 100644 index 00000000..fde2e719 --- /dev/null +++ b/packages/domain/src/domain-events/device-clean-work-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceCleanWorkChangedDomainEventProps extends DomainEventProps { + previousCleanWork?: DeviceCleanWork; + currentCleanWork: DeviceCleanWork; +} + +export class DeviceCleanWorkChangedDomainEvent extends DomainEvent { + get previousCleanWork(): DeviceCleanWork | undefined { + return this.props.previousCleanWork; + } + + get currentCleanWork(): DeviceCleanWork { + return this.props.currentCleanWork; + } + + protected validate(props: DeviceCleanWorkChangedDomainEventProps): void { + if (props.previousCleanWork) { + this.validateInstanceProp(props, 'previousCleanWork', DeviceCleanWork); + } + + this.validateDefinedProp(props, 'currentCleanWork'); + this.validateInstanceProp(props, 'currentCleanWork', DeviceCleanWork); + } +} diff --git a/packages/domain/src/domain-events/device-connected.domain-event.test.ts b/packages/domain/src/domain-events/device-connected.domain-event.test.ts new file mode 100644 index 00000000..4b986079 --- /dev/null +++ b/packages/domain/src/domain-events/device-connected.domain-event.test.ts @@ -0,0 +1,11 @@ +import { ID, DomainEvent } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceConnectedDomainEvent } from './device-connected.domain-event'; + +describe('DeviceConnectedDomainEvent', function () { + it('should be created', function () { + const deviceConnectedDomainEvent = new DeviceConnectedDomainEvent({ aggregateId: ID.generate() }); + + expect(deviceConnectedDomainEvent).to.be.instanceOf(DomainEvent); + }); +}); diff --git a/packages/domain/src/domain-events/device-connected.domain-event.ts b/packages/domain/src/domain-events/device-connected.domain-event.ts new file mode 100644 index 00000000..ceae3c0a --- /dev/null +++ b/packages/domain/src/domain-events/device-connected.domain-event.ts @@ -0,0 +1,8 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export class DeviceConnectedDomainEvent extends DomainEvent { + protected validate(_: DomainEventProps): void { + // noop + } +} diff --git a/packages/domain/src/domain-events/device-consumables-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-consumables-changed.domain-event.test.ts new file mode 100644 index 00000000..2a066aa2 --- /dev/null +++ b/packages/domain/src/domain-events/device-consumables-changed.domain-event.test.ts @@ -0,0 +1,106 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceConsumableProps } from '../test-support'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; +import { DeviceConsumablesChangedDomainEvent } from './device-consumables-changed.domain-event'; +import type { DeviceConsumablesChangedDomainEventProps } from './device-consumables-changed.domain-event'; + +describe('DeviceConsumablesChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceConsumablesChangedDomainEventProps(); + const event = new DeviceConsumablesChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousConsumables).to.be.undefined; + expect(event.currentConsumables).to.be.equal(props.currentConsumables); + }); + + it("should be created with 'previousConsumables'", function () { + const props = { + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + previousConsumables: [new DeviceConsumable(givenSomeDeviceConsumableProps())], + }; + const event = new DeviceConsumablesChangedDomainEvent(props); + + expect(event.previousConsumables).to.be.equal(props.previousConsumables); + }); + + it("should thrown an error when 'currentConsumables' is not provided", function () { + expect( + () => + new DeviceConsumablesChangedDomainEvent({ + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + // @ts-expect-error - missing property + currentConsumables: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentConsumables' for DeviceConsumablesChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousConsumables' is not an array", function () { + expect( + () => + new DeviceConsumablesChangedDomainEvent({ + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousConsumables: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousConsumables' of DeviceConsumablesChangedDomainEvent is not an array", + ); + }); + + it("should thrown an error when 'previousConsumables' is not an array of DeviceConsumable", function () { + expect( + () => + new DeviceConsumablesChangedDomainEvent({ + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousConsumables: ['foo'], + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousConsumables' of DeviceConsumablesChangedDomainEvent is not an array of DeviceConsumable", + ); + }); + + it("should thrown an error when 'currentConsumables' is not an array", function () { + expect( + () => + new DeviceConsumablesChangedDomainEvent({ + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentConsumables: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentConsumables' of DeviceConsumablesChangedDomainEvent is not an array", + ); + }); + + it("should thrown an error when 'currentConsumables' is not an array of DeviceConsumable", function () { + expect( + () => + new DeviceConsumablesChangedDomainEvent({ + ...givenSomeDeviceConsumablesChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentConsumables: ['foo'], + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentConsumables' of DeviceConsumablesChangedDomainEvent is not an array of DeviceConsumable", + ); + }); +}); + +function givenSomeDeviceConsumablesChangedDomainEventProps(): DeviceConsumablesChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousConsumables: undefined, + currentConsumables: [new DeviceConsumable(givenSomeDeviceConsumableProps())], + }; +} diff --git a/packages/domain/src/domain-events/device-consumables-changed.domain-event.ts b/packages/domain/src/domain-events/device-consumables-changed.domain-event.ts new file mode 100644 index 00000000..be7a3f87 --- /dev/null +++ b/packages/domain/src/domain-events/device-consumables-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceConsumablesChangedDomainEventProps extends DomainEventProps { + previousConsumables?: DeviceConsumable[]; + currentConsumables: DeviceConsumable[]; +} + +export class DeviceConsumablesChangedDomainEvent extends DomainEvent { + get previousConsumables(): DeviceConsumable[] | undefined { + return this.props.previousConsumables; + } + + get currentConsumables(): DeviceConsumable[] { + return this.props.currentConsumables; + } + + protected validate(props: DeviceConsumablesChangedDomainEventProps): void { + if (props.previousConsumables) { + this.validateArrayProp(props, 'previousConsumables', DeviceConsumable); + } + + this.validateDefinedProp(props, 'currentConsumables'); + this.validateArrayProp(props, 'currentConsumables', DeviceConsumable); + } +} diff --git a/packages/domain/src/domain-events/device-created.domain-event.test.ts b/packages/domain/src/domain-events/device-created.domain-event.test.ts new file mode 100644 index 00000000..734d197b --- /dev/null +++ b/packages/domain/src/domain-events/device-created.domain-event.test.ts @@ -0,0 +1,11 @@ +import { ID, DomainEvent } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceCreatedDomainEvent } from './device-created.domain-event'; + +describe('DeviceCreatedDomainEvent', function () { + it('should be created', function () { + const deviceCreatedDomainEvent = new DeviceCreatedDomainEvent({ aggregateId: ID.generate() }); + + expect(deviceCreatedDomainEvent).to.be.instanceOf(DomainEvent); + }); +}); diff --git a/packages/domain/src/domain-events/device-created.domain-event.ts b/packages/domain/src/domain-events/device-created.domain-event.ts new file mode 100644 index 00000000..051a46f9 --- /dev/null +++ b/packages/domain/src/domain-events/device-created.domain-event.ts @@ -0,0 +1,8 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export class DeviceCreatedDomainEvent extends DomainEvent { + protected validate(_: DomainEventProps): void { + // noop + } +} diff --git a/packages/domain/src/domain-events/device-error-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-error-changed.domain-event.test.ts new file mode 100644 index 00000000..16ffb864 --- /dev/null +++ b/packages/domain/src/domain-events/device-error-changed.domain-event.test.ts @@ -0,0 +1,74 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceError, DeviceErrorValue } from '../domain-primitives/device-error.domain-primitive'; +import { DeviceErrorChangedDomainEvent } from './device-error-changed.domain-event'; +import type { DeviceErrorChangedDomainEventProps } from './device-error-changed.domain-event'; + +describe('DeviceErrorChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceErrorChangedDomainEventProps(); + const event = new DeviceErrorChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousError).to.be.undefined; + expect(event.currentError).to.be.equal(props.currentError); + }); + + it("should be created with 'previousError'", function () { + const props = { + ...givenSomeDeviceErrorChangedDomainEventProps(), + previousError: new DeviceError(DeviceErrorValue.None), + }; + const event = new DeviceErrorChangedDomainEvent(props); + + expect(event.previousError).to.be.equal(props.previousError); + }); + + it("should thrown an error when 'currentError' is not provided", function () { + expect( + () => + new DeviceErrorChangedDomainEvent({ + ...givenSomeDeviceErrorChangedDomainEventProps(), + // @ts-expect-error - missing property + currentError: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'currentError' for DeviceErrorChangedDomainEvent not provided`); + }); + + it("should thrown an error when 'previousError' is not an instance of DeviceError", function () { + expect( + () => + new DeviceErrorChangedDomainEvent({ + ...givenSomeDeviceErrorChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousError: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousError' of DeviceErrorChangedDomainEvent is not an instance of DeviceError", + ); + }); + + it("should thrown an error when 'currentError' is not an instance of DeviceError", function () { + expect( + () => + new DeviceErrorChangedDomainEvent({ + ...givenSomeDeviceErrorChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentError: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentError' of DeviceErrorChangedDomainEvent is not an instance of DeviceError", + ); + }); +}); + +function givenSomeDeviceErrorChangedDomainEventProps(): DeviceErrorChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousError: undefined, + currentError: new DeviceError(DeviceErrorValue.None), + }; +} diff --git a/packages/domain/src/domain-events/device-error-changed.domain-event.ts b/packages/domain/src/domain-events/device-error-changed.domain-event.ts new file mode 100644 index 00000000..0a8bab3e --- /dev/null +++ b/packages/domain/src/domain-events/device-error-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceError } from '../domain-primitives/device-error.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceErrorChangedDomainEventProps extends DomainEventProps { + previousError?: DeviceError; + currentError: DeviceError; +} + +export class DeviceErrorChangedDomainEvent extends DomainEvent { + get previousError(): DeviceError | undefined { + return this.props.previousError; + } + + get currentError(): DeviceError { + return this.props.currentError; + } + + protected validate(props: DeviceErrorChangedDomainEventProps): void { + if (props.previousError) { + this.validateInstanceProp(props, 'previousError', DeviceError); + } + + this.validateDefinedProp(props, 'currentError'); + this.validateInstanceProp(props, 'currentError', DeviceError); + } +} diff --git a/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.test.ts new file mode 100644 index 00000000..708bc0cd --- /dev/null +++ b/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.test.ts @@ -0,0 +1,77 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceFanSpeed, DeviceFanSpeedValue } from '../domain-primitives/device-fan-speed.domain-primitive'; +import { DeviceFanSpeedChangedDomainEvent } from './device-fan-speed-changed.domain-event'; +import type { DeviceFanSpeedChangedDomainEventProps } from './device-fan-speed-changed.domain-event'; + +describe('DeviceFanSpeedChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceFanSpeedChangedDomainEventProps(); + const event = new DeviceFanSpeedChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousFanSpeed).to.be.undefined; + expect(event.currentFanSpeed).to.be.equal(props.currentFanSpeed); + }); + + it("should be created with 'previousFanSpeed'", function () { + const props = { + ...givenSomeDeviceFanSpeedChangedDomainEventProps(), + previousFanSpeed: new DeviceFanSpeed(DeviceFanSpeedValue.Low), + }; + const event = new DeviceFanSpeedChangedDomainEvent(props); + + expect(event.previousFanSpeed).to.be.equal(props.previousFanSpeed); + }); + + it("should thrown an error when 'currentFanSpeed' is not provided", function () { + expect( + () => + new DeviceFanSpeedChangedDomainEvent({ + ...givenSomeDeviceFanSpeedChangedDomainEventProps(), + // @ts-expect-error - missing property + currentFanSpeed: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentFanSpeed' for DeviceFanSpeedChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousFanSpeed' is not an instance of DeviceFanSpeed", function () { + expect( + () => + new DeviceFanSpeedChangedDomainEvent({ + ...givenSomeDeviceFanSpeedChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousFanSpeed: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousFanSpeed' of DeviceFanSpeedChangedDomainEvent is not an instance of DeviceFanSpeed", + ); + }); + + it("should thrown an error when 'currentFanSpeed' is not an instance of DeviceFanSpeed", function () { + expect( + () => + new DeviceFanSpeedChangedDomainEvent({ + ...givenSomeDeviceFanSpeedChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentFanSpeed: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentFanSpeed' of DeviceFanSpeedChangedDomainEvent is not an instance of DeviceFanSpeed", + ); + }); +}); + +function givenSomeDeviceFanSpeedChangedDomainEventProps(): DeviceFanSpeedChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousFanSpeed: undefined, + currentFanSpeed: new DeviceFanSpeed(DeviceFanSpeedValue.Low), + }; +} diff --git a/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.ts b/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.ts new file mode 100644 index 00000000..4656d7a4 --- /dev/null +++ b/packages/domain/src/domain-events/device-fan-speed-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceFanSpeed } from '../domain-primitives/device-fan-speed.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceFanSpeedChangedDomainEventProps extends DomainEventProps { + previousFanSpeed?: DeviceFanSpeed; + currentFanSpeed: DeviceFanSpeed; +} + +export class DeviceFanSpeedChangedDomainEvent extends DomainEvent { + get previousFanSpeed(): DeviceFanSpeed | undefined { + return this.props.previousFanSpeed; + } + + get currentFanSpeed(): DeviceFanSpeed { + return this.props.currentFanSpeed; + } + + protected validate(props: DeviceFanSpeedChangedDomainEventProps): void { + if (props.previousFanSpeed) { + this.validateInstanceProp(props, 'previousFanSpeed', DeviceFanSpeed); + } + + this.validateDefinedProp(props, 'currentFanSpeed'); + this.validateInstanceProp(props, 'currentFanSpeed', DeviceFanSpeed); + } +} diff --git a/packages/domain/src/domain-events/device-locked.domain-event.test.ts b/packages/domain/src/domain-events/device-locked.domain-event.test.ts new file mode 100644 index 00000000..9747e0c1 --- /dev/null +++ b/packages/domain/src/domain-events/device-locked.domain-event.test.ts @@ -0,0 +1,11 @@ +import { ID, DomainEvent } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceLockedDomainEvent } from './device-locked.domain-event'; + +describe('DeviceLockedDomainEvent', function () { + it('should be created', function () { + const deviceLockedDomainEvent = new DeviceLockedDomainEvent({ aggregateId: ID.generate() }); + + expect(deviceLockedDomainEvent).to.be.instanceOf(DomainEvent); + }); +}); diff --git a/packages/domain/src/domain-events/device-locked.domain-event.ts b/packages/domain/src/domain-events/device-locked.domain-event.ts new file mode 100644 index 00000000..fea0583d --- /dev/null +++ b/packages/domain/src/domain-events/device-locked.domain-event.ts @@ -0,0 +1,8 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export class DeviceLockedDomainEvent extends DomainEvent { + protected validate(_: DomainEventProps): void { + // noop + } +} diff --git a/packages/domain/src/domain-events/device-map-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-map-changed.domain-event.test.ts new file mode 100644 index 00000000..b176ddfb --- /dev/null +++ b/packages/domain/src/domain-events/device-map-changed.domain-event.test.ts @@ -0,0 +1,75 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceMap } from '../entities/device-map.entity'; +import { givenSomeDeviceMapProps } from '../test-support'; +import { DeviceMapChangedDomainEvent } from './device-map-changed.domain-event'; +import type { DeviceMapChangedDomainEventProps } from './device-map-changed.domain-event'; + +describe('DeviceMapChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceMapChangedDomainEventProps(); + const event = new DeviceMapChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousMap).to.be.undefined; + expect(event.currentMap).to.be.equal(props.currentMap); + }); + + it("should be created with 'previousMap'", function () { + const props = { + ...givenSomeDeviceMapChangedDomainEventProps(), + previousMap: new DeviceMap(givenSomeDeviceMapProps()), + }; + const event = new DeviceMapChangedDomainEvent(props); + + expect(event.previousMap).to.be.equal(props.previousMap); + }); + + it("should thrown an error when 'currentMap' is not provided", function () { + expect( + () => + new DeviceMapChangedDomainEvent({ + ...givenSomeDeviceMapChangedDomainEventProps(), + // @ts-expect-error - missing property + currentMap: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'currentMap' for DeviceMapChangedDomainEvent not provided`); + }); + + it("should thrown an error when 'previousMap' is not an instance of DeviceMap", function () { + expect( + () => + new DeviceMapChangedDomainEvent({ + ...givenSomeDeviceMapChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousMap: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousMap' of DeviceMapChangedDomainEvent is not an instance of DeviceMap", + ); + }); + + it("should thrown an error when 'currentMap' is not an instance of DeviceMap", function () { + expect( + () => + new DeviceMapChangedDomainEvent({ + ...givenSomeDeviceMapChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentMap: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentMap' of DeviceMapChangedDomainEvent is not an instance of DeviceMap", + ); + }); +}); + +function givenSomeDeviceMapChangedDomainEventProps(): DeviceMapChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousMap: undefined, + currentMap: new DeviceMap(givenSomeDeviceMapProps()), + }; +} diff --git a/packages/domain/src/domain-events/device-map-changed.domain-event.ts b/packages/domain/src/domain-events/device-map-changed.domain-event.ts new file mode 100644 index 00000000..81eec8d7 --- /dev/null +++ b/packages/domain/src/domain-events/device-map-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceMap } from '../entities/device-map.entity'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceMapChangedDomainEventProps extends DomainEventProps { + previousMap?: DeviceMap; + currentMap: DeviceMap; +} + +export class DeviceMapChangedDomainEvent extends DomainEvent { + get previousMap(): DeviceMap | undefined { + return this.props.previousMap; + } + + get currentMap(): DeviceMap { + return this.props.currentMap; + } + + protected validate(props: DeviceMapChangedDomainEventProps): void { + if (props.previousMap) { + this.validateInstanceProp(props, 'previousMap', DeviceMap); + } + + this.validateDefinedProp(props, 'currentMap'); + this.validateInstanceProp(props, 'currentMap', DeviceMap); + } +} diff --git a/packages/domain/src/domain-events/device-map-pending.domain-event.test.ts b/packages/domain/src/domain-events/device-map-pending.domain-event.test.ts new file mode 100644 index 00000000..0f0e8bd3 --- /dev/null +++ b/packages/domain/src/domain-events/device-map-pending.domain-event.test.ts @@ -0,0 +1,47 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceMapPendingDomainEvent } from './device-map-pending.domain-event'; +import type { DeviceMapPendingDomainEventProps } from './device-map-pending.domain-event'; + +describe('DeviceMapPendingDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceMapPendingDomainEventProps(); + const event = new DeviceMapPendingDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.isPending).to.be.true; + }); + + it("should thrown an error when 'isPending' is not provided", function () { + expect( + () => + new DeviceMapPendingDomainEvent({ + ...givenSomeDeviceMapPendingDomainEventProps(), + // @ts-expect-error - missing property + isPending: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'isPending' for DeviceMapPendingDomainEvent not provided`); + }); + + it("should thrown an error when 'isPending' is not a boolean", function () { + expect( + () => + new DeviceMapPendingDomainEvent({ + ...givenSomeDeviceMapPendingDomainEventProps(), + // @ts-expect-error - invalid property + isPending: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'isPending' of DeviceMapPendingDomainEvent is not a boolean", + ); + }); +}); + +function givenSomeDeviceMapPendingDomainEventProps(): DeviceMapPendingDomainEventProps { + return { + aggregateId: ID.generate(), + isPending: true, + }; +} diff --git a/packages/domain/src/domain-events/device-map-pending.domain-event.ts b/packages/domain/src/domain-events/device-map-pending.domain-event.ts new file mode 100644 index 00000000..352fb584 --- /dev/null +++ b/packages/domain/src/domain-events/device-map-pending.domain-event.ts @@ -0,0 +1,17 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceMapPendingDomainEventProps extends DomainEventProps { + isPending: boolean; +} + +export class DeviceMapPendingDomainEvent extends DomainEvent { + get isPending(): boolean { + return this.props.isPending; + } + + protected validate(props: DeviceMapPendingDomainEventProps): void { + this.validateDefinedProp(props, 'isPending'); + this.validateTypeProp(props, 'isPending', 'boolean'); + } +} diff --git a/packages/domain/src/domain-events/device-mode-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-mode-changed.domain-event.test.ts new file mode 100644 index 00000000..6d751ffb --- /dev/null +++ b/packages/domain/src/domain-events/device-mode-changed.domain-event.test.ts @@ -0,0 +1,74 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceMode, DeviceModeValue } from '../domain-primitives/device-mode.domain-primitive'; +import { DeviceModeChangedDomainEvent } from './device-mode-changed.domain-event'; +import type { DeviceModeChangedDomainEventProps } from './device-mode-changed.domain-event'; + +describe('DeviceModeChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceModeChangedDomainEventProps(); + const event = new DeviceModeChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousMode).to.be.undefined; + expect(event.currentMode).to.be.equal(props.currentMode); + }); + + it("should be created with 'previousMode'", function () { + const props = { + ...givenSomeDeviceModeChangedDomainEventProps(), + previousMode: new DeviceMode(DeviceModeValue.None), + }; + const event = new DeviceModeChangedDomainEvent(props); + + expect(event.previousMode).to.be.equal(props.previousMode); + }); + + it("should thrown an error when 'currentMode' is not provided", function () { + expect( + () => + new DeviceModeChangedDomainEvent({ + ...givenSomeDeviceModeChangedDomainEventProps(), + // @ts-expect-error - missing property + currentMode: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'currentMode' for DeviceModeChangedDomainEvent not provided`); + }); + + it("should thrown an error when 'previousMode' is not an instance of DeviceMode", function () { + expect( + () => + new DeviceModeChangedDomainEvent({ + ...givenSomeDeviceModeChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousMode: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousMode' of DeviceModeChangedDomainEvent is not an instance of DeviceMode", + ); + }); + + it("should thrown an error when 'currentMode' is not an instance of DeviceMode", function () { + expect( + () => + new DeviceModeChangedDomainEvent({ + ...givenSomeDeviceModeChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentMode: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentMode' of DeviceModeChangedDomainEvent is not an instance of DeviceMode", + ); + }); +}); + +function givenSomeDeviceModeChangedDomainEventProps(): DeviceModeChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousMode: undefined, + currentMode: new DeviceMode(DeviceModeValue.None), + }; +} diff --git a/packages/domain/src/domain-events/device-mode-changed.domain-event.ts b/packages/domain/src/domain-events/device-mode-changed.domain-event.ts new file mode 100644 index 00000000..435f0cac --- /dev/null +++ b/packages/domain/src/domain-events/device-mode-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceMode } from '../domain-primitives/device-mode.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceModeChangedDomainEventProps extends DomainEventProps { + previousMode?: DeviceMode; + currentMode: DeviceMode; +} + +export class DeviceModeChangedDomainEvent extends DomainEvent { + get previousMode(): DeviceMode | undefined { + return this.props.previousMode; + } + + get currentMode(): DeviceMode { + return this.props.currentMode; + } + + protected validate(props: DeviceModeChangedDomainEventProps): void { + if (props.previousMode) { + this.validateInstanceProp(props, 'previousMode', DeviceMode); + } + + this.validateDefinedProp(props, 'currentMode'); + this.validateInstanceProp(props, 'currentMode', DeviceMode); + } +} diff --git a/packages/domain/src/domain-events/device-mop-attached.domain-event.test.ts b/packages/domain/src/domain-events/device-mop-attached.domain-event.test.ts new file mode 100644 index 00000000..d24fe5e3 --- /dev/null +++ b/packages/domain/src/domain-events/device-mop-attached.domain-event.test.ts @@ -0,0 +1,47 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceMopAttachedDomainEvent } from './device-mop-attached.domain-event'; +import type { DeviceMopAttachedDomainEventProps } from './device-mop-attached.domain-event'; + +describe('DeviceMopAttachedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceMopAttachedDomainEventProps(); + const event = new DeviceMopAttachedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.isAttached).to.be.true; + }); + + it("should thrown an error when 'isAttached' is not provided", function () { + expect( + () => + new DeviceMopAttachedDomainEvent({ + ...givenSomeDeviceMopAttachedDomainEventProps(), + // @ts-expect-error - missing property + isAttached: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'isAttached' for DeviceMopAttachedDomainEvent not provided`); + }); + + it("should thrown an error when 'isAttached' is not a boolean", function () { + expect( + () => + new DeviceMopAttachedDomainEvent({ + ...givenSomeDeviceMopAttachedDomainEventProps(), + // @ts-expect-error - invalid property + isAttached: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'isAttached' of DeviceMopAttachedDomainEvent is not a boolean", + ); + }); +}); + +function givenSomeDeviceMopAttachedDomainEventProps(): DeviceMopAttachedDomainEventProps { + return { + aggregateId: ID.generate(), + isAttached: true, + }; +} diff --git a/packages/domain/src/domain-events/device-mop-attached.domain-event.ts b/packages/domain/src/domain-events/device-mop-attached.domain-event.ts new file mode 100644 index 00000000..efc7167a --- /dev/null +++ b/packages/domain/src/domain-events/device-mop-attached.domain-event.ts @@ -0,0 +1,17 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceMopAttachedDomainEventProps extends DomainEventProps { + isAttached: boolean; +} + +export class DeviceMopAttachedDomainEvent extends DomainEvent { + get isAttached(): boolean { + return this.props.isAttached; + } + + protected validate(props: DeviceMopAttachedDomainEventProps): void { + this.validateDefinedProp(props, 'isAttached'); + this.validateTypeProp(props, 'isAttached', 'boolean'); + } +} diff --git a/packages/domain/src/domain-events/device-network-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-network-changed.domain-event.test.ts new file mode 100644 index 00000000..896693b0 --- /dev/null +++ b/packages/domain/src/domain-events/device-network-changed.domain-event.test.ts @@ -0,0 +1,78 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceNetworkProps } from '../test-support'; +import { DeviceNetwork } from '../value-objects/device-network.value-object'; +import { DeviceNetworkChangedDomainEvent } from './device-network-changed.domain-event'; +import type { DeviceNetworkChangedDomainEventProps } from './device-network-changed.domain-event'; + +describe('DeviceNetworkChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceNetworkChangedDomainEventProps(); + const event = new DeviceNetworkChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousNetwork).to.be.undefined; + expect(event.currentNetwork).to.be.equal(props.currentNetwork); + }); + + it("should be created with 'previousNetwork'", function () { + const props = { + ...givenSomeDeviceNetworkChangedDomainEventProps(), + previousNetwork: new DeviceNetwork(givenSomeDeviceNetworkProps()), + }; + const event = new DeviceNetworkChangedDomainEvent(props); + + expect(event.previousNetwork).to.be.equal(props.previousNetwork); + }); + + it("should thrown an error when 'currentNetwork' is not provided", function () { + expect( + () => + new DeviceNetworkChangedDomainEvent({ + ...givenSomeDeviceNetworkChangedDomainEventProps(), + // @ts-expect-error - missing property + currentNetwork: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentNetwork' for DeviceNetworkChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousNetwork' is not an instance of DeviceNetwork", function () { + expect( + () => + new DeviceNetworkChangedDomainEvent({ + ...givenSomeDeviceNetworkChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousNetwork: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousNetwork' of DeviceNetworkChangedDomainEvent is not an instance of DeviceNetwork", + ); + }); + + it("should thrown an error when 'currentNetwork' is not an instance of DeviceNetwork", function () { + expect( + () => + new DeviceNetworkChangedDomainEvent({ + ...givenSomeDeviceNetworkChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentNetwork: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentNetwork' of DeviceNetworkChangedDomainEvent is not an instance of DeviceNetwork", + ); + }); +}); + +function givenSomeDeviceNetworkChangedDomainEventProps(): DeviceNetworkChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousNetwork: undefined, + currentNetwork: new DeviceNetwork(givenSomeDeviceNetworkProps()), + }; +} diff --git a/packages/domain/src/domain-events/device-network-changed.domain-event.ts b/packages/domain/src/domain-events/device-network-changed.domain-event.ts new file mode 100644 index 00000000..f64d3ed8 --- /dev/null +++ b/packages/domain/src/domain-events/device-network-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceNetwork } from '../value-objects/device-network.value-object'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceNetworkChangedDomainEventProps extends DomainEventProps { + previousNetwork?: DeviceNetwork; + currentNetwork: DeviceNetwork; +} + +export class DeviceNetworkChangedDomainEvent extends DomainEvent { + get previousNetwork(): DeviceNetwork | undefined { + return this.props.previousNetwork; + } + + get currentNetwork(): DeviceNetwork { + return this.props.currentNetwork; + } + + protected validate(props: DeviceNetworkChangedDomainEventProps): void { + if (props.previousNetwork) { + this.validateInstanceProp(props, 'previousNetwork', DeviceNetwork); + } + + this.validateDefinedProp(props, 'currentNetwork'); + this.validateInstanceProp(props, 'currentNetwork', DeviceNetwork); + } +} diff --git a/packages/domain/src/domain-events/device-orders-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-orders-changed.domain-event.test.ts new file mode 100644 index 00000000..2ace2d52 --- /dev/null +++ b/packages/domain/src/domain-events/device-orders-changed.domain-event.test.ts @@ -0,0 +1,106 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceOrder } from '../entities/device-order.entity'; +import { givenSomeDeviceOrderProps } from '../test-support'; +import { DeviceOrdersChangedDomainEvent } from './device-orders-changed.domain-event'; +import type { DeviceOrdersChangedDomainEventProps } from './device-orders-changed.domain-event'; + +describe('DeviceOrdersChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceOrdersChangedDomainEventProps(); + const event = new DeviceOrdersChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousOrders).to.be.undefined; + expect(event.currentOrders).to.be.equal(props.currentOrders); + }); + + it("should be created with 'previousOrders'", function () { + const props = { + ...givenSomeDeviceOrdersChangedDomainEventProps(), + previousOrders: [new DeviceOrder(givenSomeDeviceOrderProps())], + }; + const event = new DeviceOrdersChangedDomainEvent(props); + + expect(event.previousOrders).to.be.equal(props.previousOrders); + }); + + it("should thrown an error when 'currentOrders' is not provided", function () { + expect( + () => + new DeviceOrdersChangedDomainEvent({ + ...givenSomeDeviceOrdersChangedDomainEventProps(), + // @ts-expect-error - missing property + currentOrders: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentOrders' for DeviceOrdersChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousOrders' is not an array", function () { + expect( + () => + new DeviceOrdersChangedDomainEvent({ + ...givenSomeDeviceOrdersChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousOrders: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousOrders' of DeviceOrdersChangedDomainEvent is not an array", + ); + }); + + it("should thrown an error when 'previousOrders' is not an array of DeviceOrder", function () { + expect( + () => + new DeviceOrdersChangedDomainEvent({ + ...givenSomeDeviceOrdersChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousOrders: ['foo'], + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousOrders' of DeviceOrdersChangedDomainEvent is not an array of DeviceOrder", + ); + }); + + it("should thrown an error when 'currentOrders' is not an array", function () { + expect( + () => + new DeviceOrdersChangedDomainEvent({ + ...givenSomeDeviceOrdersChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentOrders: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentOrders' of DeviceOrdersChangedDomainEvent is not an array", + ); + }); + + it("should thrown an error when 'currentOrders' is not an array of DeviceOrder", function () { + expect( + () => + new DeviceOrdersChangedDomainEvent({ + ...givenSomeDeviceOrdersChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentOrders: ['foo'], + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentOrders' of DeviceOrdersChangedDomainEvent is not an array of DeviceOrder", + ); + }); +}); + +function givenSomeDeviceOrdersChangedDomainEventProps(): DeviceOrdersChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousOrders: undefined, + currentOrders: [new DeviceOrder(givenSomeDeviceOrderProps())], + }; +} diff --git a/packages/domain/src/domain-events/device-orders-changed.domain-event.ts b/packages/domain/src/domain-events/device-orders-changed.domain-event.ts new file mode 100644 index 00000000..769cd229 --- /dev/null +++ b/packages/domain/src/domain-events/device-orders-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceOrder } from '../entities/device-order.entity'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceOrdersChangedDomainEventProps extends DomainEventProps { + previousOrders?: DeviceOrder[]; + currentOrders: DeviceOrder[]; +} + +export class DeviceOrdersChangedDomainEvent extends DomainEvent { + get previousOrders(): DeviceOrder[] | undefined { + return this.props.previousOrders; + } + + get currentOrders(): DeviceOrder[] { + return this.props.currentOrders; + } + + protected validate(props: DeviceOrdersChangedDomainEventProps): void { + if (props.previousOrders) { + this.validateArrayProp(props, 'previousOrders', DeviceOrder); + } + + this.validateDefinedProp(props, 'currentOrders'); + this.validateArrayProp(props, 'currentOrders', DeviceOrder); + } +} diff --git a/packages/domain/src/domain-events/device-settings-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-settings-changed.domain-event.test.ts new file mode 100644 index 00000000..7dc61ab4 --- /dev/null +++ b/packages/domain/src/domain-events/device-settings-changed.domain-event.test.ts @@ -0,0 +1,78 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceSettingsProps } from '../test-support'; +import { DeviceSettings } from '../value-objects/device-settings.value-object'; +import { DeviceSettingsChangedDomainEvent } from './device-settings-changed.domain-event'; +import type { DeviceSettingsChangedDomainEventProps } from './device-settings-changed.domain-event'; + +describe('DeviceSettingsChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceSettingsChangedDomainEventProps(); + const event = new DeviceSettingsChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousSettings).to.be.undefined; + expect(event.currentSettings).to.be.equal(props.currentSettings); + }); + + it("should be created with 'previousSettings'", function () { + const props = { + ...givenSomeDeviceSettingsChangedDomainEventProps(), + previousSettings: new DeviceSettings(givenSomeDeviceSettingsProps()), + }; + const event = new DeviceSettingsChangedDomainEvent(props); + + expect(event.previousSettings).to.be.equal(props.previousSettings); + }); + + it("should thrown an error when 'currentSettings' is not provided", function () { + expect( + () => + new DeviceSettingsChangedDomainEvent({ + ...givenSomeDeviceSettingsChangedDomainEventProps(), + // @ts-expect-error - missing property + currentSettings: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentSettings' for DeviceSettingsChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousSettings' is not an instance of DeviceSettings", function () { + expect( + () => + new DeviceSettingsChangedDomainEvent({ + ...givenSomeDeviceSettingsChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousSettings: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousSettings' of DeviceSettingsChangedDomainEvent is not an instance of DeviceSettings", + ); + }); + + it("should thrown an error when 'currentSettings' is not an instance of DeviceSettings", function () { + expect( + () => + new DeviceSettingsChangedDomainEvent({ + ...givenSomeDeviceSettingsChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentSettings: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentSettings' of DeviceSettingsChangedDomainEvent is not an instance of DeviceSettings", + ); + }); +}); + +function givenSomeDeviceSettingsChangedDomainEventProps(): DeviceSettingsChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousSettings: undefined, + currentSettings: new DeviceSettings(givenSomeDeviceSettingsProps()), + }; +} diff --git a/packages/domain/src/domain-events/device-settings-changed.domain-event.ts b/packages/domain/src/domain-events/device-settings-changed.domain-event.ts new file mode 100644 index 00000000..46c895c9 --- /dev/null +++ b/packages/domain/src/domain-events/device-settings-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceSettings } from '../value-objects/device-settings.value-object'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceSettingsChangedDomainEventProps extends DomainEventProps { + previousSettings?: DeviceSettings; + currentSettings: DeviceSettings; +} + +export class DeviceSettingsChangedDomainEvent extends DomainEvent { + get previousSettings(): DeviceSettings | undefined { + return this.props.previousSettings; + } + + get currentSettings(): DeviceSettings { + return this.props.currentSettings; + } + + protected validate(props: DeviceSettingsChangedDomainEventProps): void { + if (props.previousSettings) { + this.validateInstanceProp(props, 'previousSettings', DeviceSettings); + } + + this.validateDefinedProp(props, 'currentSettings'); + this.validateInstanceProp(props, 'currentSettings', DeviceSettings); + } +} diff --git a/packages/domain/src/domain-events/device-state-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-state-changed.domain-event.test.ts new file mode 100644 index 00000000..54734842 --- /dev/null +++ b/packages/domain/src/domain-events/device-state-changed.domain-event.test.ts @@ -0,0 +1,74 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceState, DeviceStateValue } from '../domain-primitives/device-state.domain-primitive'; +import { DeviceStateChangedDomainEvent } from './device-state-changed.domain-event'; +import type { DeviceStateChangedDomainEventProps } from './device-state-changed.domain-event'; + +describe('DeviceStateChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceStateChangedDomainEventProps(); + const event = new DeviceStateChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousState).to.be.undefined; + expect(event.currentState).to.be.equal(props.currentState); + }); + + it("should be created with 'previousState'", function () { + const props = { + ...givenSomeDeviceStateChangedDomainEventProps(), + previousState: new DeviceState(DeviceStateValue.Idle), + }; + const event = new DeviceStateChangedDomainEvent(props); + + expect(event.previousState).to.be.equal(props.previousState); + }); + + it("should thrown an error when 'currentState' is not provided", function () { + expect( + () => + new DeviceStateChangedDomainEvent({ + ...givenSomeDeviceStateChangedDomainEventProps(), + // @ts-expect-error - missing property + currentState: undefined, + }), + ).to.throw(ArgumentNotProvidedException, `Property 'currentState' for DeviceStateChangedDomainEvent not provided`); + }); + + it("should thrown an error when 'previousState' is not an instance of DeviceState", function () { + expect( + () => + new DeviceStateChangedDomainEvent({ + ...givenSomeDeviceStateChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousState: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousState' of DeviceStateChangedDomainEvent is not an instance of DeviceState", + ); + }); + + it("should thrown an error when 'currentState' is not an instance of DeviceState", function () { + expect( + () => + new DeviceStateChangedDomainEvent({ + ...givenSomeDeviceStateChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentState: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentState' of DeviceStateChangedDomainEvent is not an instance of DeviceState", + ); + }); +}); + +function givenSomeDeviceStateChangedDomainEventProps(): DeviceStateChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousState: undefined, + currentState: new DeviceState(DeviceStateValue.Idle), + }; +} diff --git a/packages/domain/src/domain-events/device-state-changed.domain-event.ts b/packages/domain/src/domain-events/device-state-changed.domain-event.ts new file mode 100644 index 00000000..522878be --- /dev/null +++ b/packages/domain/src/domain-events/device-state-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceState } from '../domain-primitives/device-state.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceStateChangedDomainEventProps extends DomainEventProps { + previousState?: DeviceState; + currentState: DeviceState; +} + +export class DeviceStateChangedDomainEvent extends DomainEvent { + get previousState(): DeviceState | undefined { + return this.props.previousState; + } + + get currentState(): DeviceState { + return this.props.currentState; + } + + protected validate(props: DeviceStateChangedDomainEventProps): void { + if (props.previousState) { + this.validateInstanceProp(props, 'previousState', DeviceState); + } + + this.validateDefinedProp(props, 'currentState'); + this.validateInstanceProp(props, 'currentState', DeviceState); + } +} diff --git a/packages/domain/src/domain-events/device-version-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-version-changed.domain-event.test.ts new file mode 100644 index 00000000..cb68d24e --- /dev/null +++ b/packages/domain/src/domain-events/device-version-changed.domain-event.test.ts @@ -0,0 +1,81 @@ +import { DomainEvent, ID, ArgumentInvalidException, ArgumentNotProvidedException } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceVersion } from '../value-objects/device-version.value-object'; +import { DeviceVersionChangedDomainEvent } from './device-version-changed.domain-event'; +import type { DeviceVersionChangedDomainEventProps } from './device-version-changed.domain-event'; + +describe('DeviceVersionChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceVersionChangedDomainEventProps(); + const event = new DeviceVersionChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousVersion).to.be.equal(props.previousVersion); + expect(event.currentVersion).to.be.equal(props.currentVersion); + }); + + it("should thrown an error when 'previousVersion' is not provided", function () { + expect( + () => + new DeviceVersionChangedDomainEvent({ + ...givenSomeDeviceVersionChangedDomainEventProps(), + // @ts-expect-error - missing property + previousVersion: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'previousVersion' for DeviceVersionChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'currentVersion' is not provided", function () { + expect( + () => + new DeviceVersionChangedDomainEvent({ + ...givenSomeDeviceVersionChangedDomainEventProps(), + // @ts-expect-error - missing property + currentVersion: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentVersion' for DeviceVersionChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousVersion' is not an instance of ID", function () { + expect( + () => + new DeviceVersionChangedDomainEvent({ + ...givenSomeDeviceVersionChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousVersion: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousVersion' of DeviceVersionChangedDomainEvent is not an instance of DeviceVersion", + ); + }); + + it("should thrown an error when 'currentVersion' is not an instance of ID", function () { + expect( + () => + new DeviceVersionChangedDomainEvent({ + ...givenSomeDeviceVersionChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentVersion: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentVersion' of DeviceVersionChangedDomainEvent is not an instance of DeviceVersion", + ); + }); +}); + +function givenSomeDeviceVersionChangedDomainEventProps(): DeviceVersionChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousVersion: new DeviceVersion({ software: '1.0.0', hardware: '1.0.0' }), + currentVersion: new DeviceVersion({ software: '1.0.1', hardware: '1.0.1' }), + }; +} diff --git a/packages/domain/src/domain-events/device-version-changed.domain-event.ts b/packages/domain/src/domain-events/device-version-changed.domain-event.ts new file mode 100644 index 00000000..7d46b192 --- /dev/null +++ b/packages/domain/src/domain-events/device-version-changed.domain-event.ts @@ -0,0 +1,25 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceVersion } from '../value-objects/device-version.value-object'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceVersionChangedDomainEventProps extends DomainEventProps { + previousVersion: DeviceVersion; + currentVersion: DeviceVersion; +} + +export class DeviceVersionChangedDomainEvent extends DomainEvent { + get previousVersion(): DeviceVersion { + return this.props.previousVersion; + } + + get currentVersion(): DeviceVersion { + return this.props.currentVersion; + } + + protected validate(props: DeviceVersionChangedDomainEventProps): void { + this.validateDefinedProp(props, 'previousVersion'); + this.validateInstanceProp(props, 'previousVersion', DeviceVersion); + this.validateDefinedProp(props, 'currentVersion'); + this.validateInstanceProp(props, 'currentVersion', DeviceVersion); + } +} diff --git a/packages/domain/src/domain-events/device-water-level-changed.domain-event.test.ts b/packages/domain/src/domain-events/device-water-level-changed.domain-event.test.ts new file mode 100644 index 00000000..b2e95597 --- /dev/null +++ b/packages/domain/src/domain-events/device-water-level-changed.domain-event.test.ts @@ -0,0 +1,77 @@ +import { DomainEvent, ArgumentNotProvidedException, ArgumentInvalidException, ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DeviceWaterLevel, DeviceWaterLevelValue } from '../domain-primitives/device-water-level.domain-primitive'; +import { DeviceWaterLevelChangedDomainEvent } from './device-water-level-changed.domain-event'; +import type { DeviceWaterLevelChangedDomainEventProps } from './device-water-level-changed.domain-event'; + +describe('DeviceWaterLevelChangedDomainEvent', function () { + it('should be created', function () { + const props = givenSomeDeviceWaterLevelChangedDomainEventProps(); + const event = new DeviceWaterLevelChangedDomainEvent(props); + + expect(event).to.be.instanceOf(DomainEvent); + expect(event.aggregateId).to.be.equal(props.aggregateId); + expect(event.previousWaterLevel).to.be.undefined; + expect(event.currentWaterLevel).to.be.equal(props.currentWaterLevel); + }); + + it("should be created with 'previousWaterLevel'", function () { + const props = { + ...givenSomeDeviceWaterLevelChangedDomainEventProps(), + previousWaterLevel: new DeviceWaterLevel(DeviceWaterLevelValue.Low), + }; + const event = new DeviceWaterLevelChangedDomainEvent(props); + + expect(event.previousWaterLevel).to.be.equal(props.previousWaterLevel); + }); + + it("should thrown an error when 'currentWaterLevel' is not provided", function () { + expect( + () => + new DeviceWaterLevelChangedDomainEvent({ + ...givenSomeDeviceWaterLevelChangedDomainEventProps(), + // @ts-expect-error - missing property + currentWaterLevel: undefined, + }), + ).to.throw( + ArgumentNotProvidedException, + `Property 'currentWaterLevel' for DeviceWaterLevelChangedDomainEvent not provided`, + ); + }); + + it("should thrown an error when 'previousWaterLevel' is not an instance of DeviceWaterLevel", function () { + expect( + () => + new DeviceWaterLevelChangedDomainEvent({ + ...givenSomeDeviceWaterLevelChangedDomainEventProps(), + // @ts-expect-error - invalid property + previousWaterLevel: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'previousWaterLevel' of DeviceWaterLevelChangedDomainEvent is not an instance of DeviceWaterLevel", + ); + }); + + it("should thrown an error when 'currentWaterLevel' is not an instance of DeviceWaterLevel", function () { + expect( + () => + new DeviceWaterLevelChangedDomainEvent({ + ...givenSomeDeviceWaterLevelChangedDomainEventProps(), + // @ts-expect-error - invalid property + currentWaterLevel: 'foo', + }), + ).to.throw( + ArgumentInvalidException, + "Value 'foo' for property 'currentWaterLevel' of DeviceWaterLevelChangedDomainEvent is not an instance of DeviceWaterLevel", + ); + }); +}); + +function givenSomeDeviceWaterLevelChangedDomainEventProps(): DeviceWaterLevelChangedDomainEventProps { + return { + aggregateId: ID.generate(), + previousWaterLevel: undefined, + currentWaterLevel: new DeviceWaterLevel(DeviceWaterLevelValue.Low), + }; +} diff --git a/packages/domain/src/domain-events/device-water-level-changed.domain-event.ts b/packages/domain/src/domain-events/device-water-level-changed.domain-event.ts new file mode 100644 index 00000000..006d8e97 --- /dev/null +++ b/packages/domain/src/domain-events/device-water-level-changed.domain-event.ts @@ -0,0 +1,27 @@ +import { DomainEvent } from '@agnoc/toolkit'; +import { DeviceWaterLevel } from '../domain-primitives/device-water-level.domain-primitive'; +import type { DomainEventProps } from '@agnoc/toolkit'; + +export interface DeviceWaterLevelChangedDomainEventProps extends DomainEventProps { + previousWaterLevel?: DeviceWaterLevel; + currentWaterLevel: DeviceWaterLevel; +} + +export class DeviceWaterLevelChangedDomainEvent extends DomainEvent { + get previousWaterLevel(): DeviceWaterLevel | undefined { + return this.props.previousWaterLevel; + } + + get currentWaterLevel(): DeviceWaterLevel { + return this.props.currentWaterLevel; + } + + protected validate(props: DeviceWaterLevelChangedDomainEventProps): void { + if (props.previousWaterLevel) { + this.validateInstanceProp(props, 'previousWaterLevel', DeviceWaterLevel); + } + + this.validateDefinedProp(props, 'currentWaterLevel'); + this.validateInstanceProp(props, 'currentWaterLevel', DeviceWaterLevel); + } +} diff --git a/packages/domain/src/domain-events/domain-events.ts b/packages/domain/src/domain-events/domain-events.ts new file mode 100644 index 00000000..703a36d6 --- /dev/null +++ b/packages/domain/src/domain-events/domain-events.ts @@ -0,0 +1,44 @@ +import { ConnectionDeviceChangedDomainEvent } from './connection-device-changed.domain-event'; +import { DeviceBatteryChangedDomainEvent } from './device-battery-changed.domain-event'; +import { DeviceCleanWorkChangedDomainEvent } from './device-clean-work-changed.domain-event'; +import { DeviceConnectedDomainEvent } from './device-connected.domain-event'; +import { DeviceCreatedDomainEvent } from './device-created.domain-event'; +import { DeviceErrorChangedDomainEvent } from './device-error-changed.domain-event'; +import { DeviceFanSpeedChangedDomainEvent } from './device-fan-speed-changed.domain-event'; +import { DeviceLockedDomainEvent } from './device-locked.domain-event'; +import { DeviceMapChangedDomainEvent } from './device-map-changed.domain-event'; +import { DeviceMapPendingDomainEvent } from './device-map-pending.domain-event'; +import { DeviceModeChangedDomainEvent } from './device-mode-changed.domain-event'; +import { DeviceMopAttachedDomainEvent } from './device-mop-attached.domain-event'; +import { DeviceNetworkChangedDomainEvent } from './device-network-changed.domain-event'; +import { DeviceOrdersChangedDomainEvent } from './device-orders-changed.domain-event'; +import { DeviceSettingsChangedDomainEvent } from './device-settings-changed.domain-event'; +import { DeviceStateChangedDomainEvent } from './device-state-changed.domain-event'; +import { DeviceVersionChangedDomainEvent } from './device-version-changed.domain-event'; +import { DeviceWaterLevelChangedDomainEvent } from './device-water-level-changed.domain-event'; +import type { InstanceTypeProps } from '@agnoc/toolkit'; + +export const DomainEvents = { + ConnectionDeviceChangedDomainEvent, + DeviceBatteryChangedDomainEvent, + DeviceCleanWorkChangedDomainEvent, + DeviceConnectedDomainEvent, + DeviceCreatedDomainEvent, + DeviceErrorChangedDomainEvent, + DeviceFanSpeedChangedDomainEvent, + DeviceLockedDomainEvent, + DeviceMapChangedDomainEvent, + DeviceMapPendingDomainEvent, + DeviceModeChangedDomainEvent, + DeviceMopAttachedDomainEvent, + DeviceNetworkChangedDomainEvent, + DeviceOrdersChangedDomainEvent, + DeviceSettingsChangedDomainEvent, + DeviceStateChangedDomainEvent, + DeviceVersionChangedDomainEvent, + DeviceWaterLevelChangedDomainEvent, +}; + +export type DomainEvents = InstanceTypeProps; + +export type DomainEventNames = keyof DomainEvents; diff --git a/packages/domain/src/domain-primitives/device-state.domain-primitive.ts b/packages/domain/src/domain-primitives/device-state.domain-primitive.ts index 5d64309c..b025f6f2 100644 --- a/packages/domain/src/domain-primitives/device-state.domain-primitive.ts +++ b/packages/domain/src/domain-primitives/device-state.domain-primitive.ts @@ -2,14 +2,14 @@ import { DomainPrimitive } from '@agnoc/toolkit'; /** The possible values of a device state. */ export enum DeviceStateValue { - Error = 'error', - Docked = 'docked', - Idle = 'idle', - Returning = 'returning', - Cleaning = 'cleaning', - Paused = 'paused', - ManualControl = 'manual_control', - Moving = 'moving', + Error = 'Error', + Docked = 'Docked', + Idle = 'Idle', + Returning = 'Returning', + Cleaning = 'Cleaning', + Paused = 'Paused', + ManualControl = 'ManualControl', + Moving = 'Moving', } /** Describe the state of a device. */ diff --git a/packages/domain/src/entities/device.entity.test.ts b/packages/domain/src/entities/device.entity.test.ts deleted file mode 100644 index cf18a4bc..00000000 --- a/packages/domain/src/entities/device.entity.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { ArgumentInvalidException, ArgumentNotProvidedException, Entity } from '@agnoc/toolkit'; -import { expect } from 'chai'; -import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; -import { DeviceError, DeviceErrorValue } from '../domain-primitives/device-error.domain-primitive'; -import { DeviceFanSpeed, DeviceFanSpeedValue } from '../domain-primitives/device-fan-speed.domain-primitive'; -import { DeviceMode, DeviceModeValue } from '../domain-primitives/device-mode.domain-primitive'; -import { DeviceState, DeviceStateValue } from '../domain-primitives/device-state.domain-primitive'; -import { DeviceWaterLevel, DeviceWaterLevelValue } from '../domain-primitives/device-water-level.domain-primitive'; -import { - givenSomeDeviceCleanWorkProps, - givenSomeDeviceConsumableProps, - givenSomeDeviceMapProps, - givenSomeDeviceOrderProps, - givenSomeDeviceProps, - givenSomeDeviceSettingsProps, - givenSomeDeviceSystemProps, - givenSomeDeviceVersionProps, - givenSomeDeviceWlanProps, -} from '../test-support'; -import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; -import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; -import { DeviceSettings } from '../value-objects/device-settings.value-object'; -import { DeviceSystem } from '../value-objects/device-system.value-object'; -import { DeviceVersion } from '../value-objects/device-version.value-object'; -import { DeviceWlan } from '../value-objects/device-wlan.value-object'; -import { DeviceMap } from './device-map.entity'; -import { DeviceOrder } from './device-order.entity'; -import { Device } from './device.entity'; - -describe('Device', function () { - it('should be created', function () { - const deviceProps = { - ...givenSomeDeviceProps(), - orders: [new DeviceOrder(givenSomeDeviceOrderProps())], - consumables: [new DeviceConsumable(givenSomeDeviceConsumableProps())], - }; - const device = new Device(deviceProps); - - expect(device).to.be.instanceOf(Entity); - expect(device.id).to.be.equal(deviceProps.id); - expect(device.system).to.be.equal(deviceProps.system); - expect(device.version).to.be.equal(deviceProps.version); - }); - - it("should throw an error when 'system' is not provided", function () { - // @ts-expect-error - missing property - expect(() => new Device({ ...givenSomeDeviceProps(), system: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'system' for Device not provided`, - ); - }); - - it("should throw an error when 'version' is not provided", function () { - // @ts-expect-error - missing property - expect(() => new Device({ ...givenSomeDeviceProps(), version: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'version' for Device not provided`, - ); - }); - - it("should throw an error when 'system' is not a DeviceSystem", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), system: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'system' of Device is not an instance of DeviceSystem`, - ); - }); - - it("should throw an error when 'version' is not a DeviceVersion", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), version: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'version' of Device is not an instance of DeviceVersion`, - ); - }); - - it("should throw an error when 'config' is not a DeviceSettings", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), config: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'config' of Device is not an instance of DeviceSettings`, - ); - }); - - it("should throw an error when 'currentClean' is not a DeviceCleanWork", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), currentClean: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'currentClean' of Device is not an instance of DeviceCleanWork`, - ); - }); - - it("should throw an error when 'orders' is not an array", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), orders: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'orders' of Device is not an array`, - ); - }); - - it("should throw an error when 'orders' is not an array of DeviceOrder", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), orders: ['foo', 1] })).to.throw( - ArgumentInvalidException, - `Value 'foo, 1' for property 'orders' of Device is not an array of DeviceOrder`, - ); - }); - - it("should throw an error when 'consumables' is not an array", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), consumables: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'consumables' of Device is not an array`, - ); - }); - - it("should throw an error when 'consumables' is not an array of DeviceConsumable", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), consumables: ['foo', 1] })).to.throw( - ArgumentInvalidException, - `Value 'foo, 1' for property 'consumables' of Device is not an array of DeviceConsumable`, - ); - }); - - it("should throw an error when 'map' is not a DeviceMap", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), map: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'map' of Device is not an instance of DeviceMap`, - ); - }); - - it("should throw an error when 'wlan' is not a DeviceWlan", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), wlan: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'wlan' of Device is not an instance of DeviceWlan`, - ); - }); - - it("should throw an error when 'battery' is not a DeviceBattery", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), battery: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'battery' of Device is not an instance of DeviceBattery`, - ); - }); - - it("should throw an error when 'state' is not a DeviceState", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), state: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'state' of Device is not an instance of DeviceState`, - ); - }); - - it("should throw an error when 'mode' is not a DeviceMode", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), mode: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'mode' of Device is not an instance of DeviceMode`, - ); - }); - - it("should throw an error when 'error' is not a DeviceError", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), error: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'error' of Device is not an instance of DeviceError`, - ); - }); - - it("should throw an error when 'fanSpeed' is not a DeviceFanSpeed", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), fanSpeed: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'fanSpeed' of Device is not an instance of DeviceFanSpeed`, - ); - }); - - it("should throw an error when 'waterLevel' is not a DeviceWaterLevel", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), waterLevel: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'waterLevel' of Device is not an instance of DeviceWaterLevel`, - ); - }); - - it("should throw an error when 'hasMopAttached' is not a boolean", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), hasMopAttached: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'hasMopAttached' of Device is not a boolean`, - ); - }); - - it("should throw an error when 'hasWaitingMap' is not a boolean", function () { - // @ts-expect-error - invalid property - expect(() => new Device({ ...givenSomeDeviceProps(), hasWaitingMap: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'hasWaitingMap' of Device is not a boolean`, - ); - }); - - describe('#updateSystem()', function () { - it('should update the device system', function () { - const device = new Device(givenSomeDeviceProps()); - const system = new DeviceSystem(givenSomeDeviceSystemProps()); - - device.updateSystem(system); - - expect(device.system).to.be.equal(system); - }); - }); - - describe('#updateVersion()', function () { - it('should update the device version', function () { - const device = new Device(givenSomeDeviceProps()); - const version = new DeviceVersion(givenSomeDeviceVersionProps()); - - device.updateVersion(version); - - expect(device.version).to.be.equal(version); - }); - }); - - describe('#updateConfig()', function () { - it('should update the device config', function () { - const device = new Device(givenSomeDeviceProps()); - const config = new DeviceSettings(givenSomeDeviceSettingsProps()); - - device.updateConfig(config); - - expect(device.config).to.be.equal(config); - }); - }); - - describe('#updateCurrentClean()', function () { - it('should update the device current clean', function () { - const device = new Device(givenSomeDeviceProps()); - const currentClean = new DeviceCleanWork(givenSomeDeviceCleanWorkProps()); - - device.updateCurrentClean(currentClean); - - expect(device.currentClean).to.be.equal(currentClean); - }); - }); - - describe('#updateOrders()', function () { - it('should update the device orders', function () { - const device = new Device(givenSomeDeviceProps()); - const orders = [new DeviceOrder(givenSomeDeviceOrderProps())]; - - device.updateOrders(orders); - - expect(device.orders).to.be.equal(orders); - }); - }); - - describe('#updateConsumables()', function () { - it('should update the device consumables', function () { - const device = new Device(givenSomeDeviceProps()); - const consumables = [new DeviceConsumable(givenSomeDeviceConsumableProps())]; - - device.updateConsumables(consumables); - - expect(device.consumables).to.be.equal(consumables); - }); - }); - - describe('#updateMap()', function () { - it('should update the device map', function () { - const device = new Device(givenSomeDeviceProps()); - const map = new DeviceMap(givenSomeDeviceMapProps()); - - device.updateMap(map); - - expect(device.map).to.be.equal(map); - }); - }); - - describe('#updateWlan()', function () { - it('should update the device wlan', function () { - const device = new Device(givenSomeDeviceProps()); - const wlan = new DeviceWlan(givenSomeDeviceWlanProps()); - - device.updateWlan(wlan); - - expect(device.wlan).to.be.equal(wlan); - }); - }); - - describe('#updateBattery()', function () { - it('should update the device battery', function () { - const device = new Device(givenSomeDeviceProps()); - const battery = new DeviceBattery(50); - - device.updateBattery(battery); - - expect(device.battery).to.be.equal(battery); - }); - }); - - describe('#updateState()', function () { - it('should update the device state', function () { - const device = new Device(givenSomeDeviceProps()); - const state = new DeviceState(DeviceStateValue.Idle); - - device.updateState(state); - - expect(device.state).to.be.equal(state); - }); - }); - - describe('#updateMode()', function () { - it('should update the device mode', function () { - const device = new Device(givenSomeDeviceProps()); - const mode = new DeviceMode(DeviceModeValue.Spot); - - device.updateMode(mode); - - expect(device.mode).to.be.equal(mode); - }); - }); - - describe('#updateError()', function () { - it('should update the device error', function () { - const device = new Device(givenSomeDeviceProps()); - const error = new DeviceError(DeviceErrorValue.None); - - device.updateError(error); - - expect(device.error).to.be.equal(error); - }); - }); - - describe('#updateFanSpeed()', function () { - it('should update the device fan speed', function () { - const device = new Device(givenSomeDeviceProps()); - const fanSpeed = new DeviceFanSpeed(DeviceFanSpeedValue.Low); - - device.updateFanSpeed(fanSpeed); - - expect(device.fanSpeed).to.be.equal(fanSpeed); - }); - }); - - describe('#updateWaterLevel()', function () { - it('should update the device water level', function () { - const device = new Device(givenSomeDeviceProps()); - const waterLevel = new DeviceWaterLevel(DeviceWaterLevelValue.Low); - - device.updateWaterLevel(waterLevel); - - expect(device.waterLevel).to.be.equal(waterLevel); - }); - }); - - describe('#updateHasMopAttached()', function () { - it('should update the device has mop attached', function () { - const device = new Device({ ...givenSomeDeviceProps(), hasMopAttached: true }); - - device.updateHasMopAttached(false); - - expect(device.hasMopAttached).to.be.equal(false); - }); - }); - - describe('#updateHasWaitingMap()', function () { - it('should update the device has waiting map', function () { - const device = new Device({ ...givenSomeDeviceProps(), hasWaitingMap: true }); - - device.updateHasWaitingMap(false); - - expect(device.hasWaitingMap).to.be.equal(false); - }); - }); -}); diff --git a/packages/domain/src/entities/device.entity.ts b/packages/domain/src/entities/device.entity.ts deleted file mode 100644 index 8f88656d..00000000 --- a/packages/domain/src/entities/device.entity.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { Entity } from '@agnoc/toolkit'; -import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive'; -import { DeviceError } from '../domain-primitives/device-error.domain-primitive'; -import { DeviceFanSpeed } from '../domain-primitives/device-fan-speed.domain-primitive'; -import { DeviceMode } from '../domain-primitives/device-mode.domain-primitive'; -import { DeviceState } from '../domain-primitives/device-state.domain-primitive'; -import { DeviceWaterLevel } from '../domain-primitives/device-water-level.domain-primitive'; -import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object'; -import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; -import { DeviceSettings } from '../value-objects/device-settings.value-object'; -import { DeviceSystem } from '../value-objects/device-system.value-object'; -import { DeviceVersion } from '../value-objects/device-version.value-object'; -import { DeviceWlan } from '../value-objects/device-wlan.value-object'; -import { DeviceMap } from './device-map.entity'; -import { DeviceOrder } from './device-order.entity'; -import type { EntityProps } from '@agnoc/toolkit'; - -/** Describes the properties of a device. */ -export interface DeviceProps extends EntityProps { - /** The device system. */ - system: DeviceSystem; - /** The device version. */ - version: DeviceVersion; - /** The device settings. */ - config?: DeviceSettings; - /** The device current clean. */ - currentClean?: DeviceCleanWork; - /** The device orders. */ - orders?: DeviceOrder[]; - /** The device consumables. */ - consumables?: DeviceConsumable[]; - /** The device map. */ - map?: DeviceMap; - /** The device wlan. */ - wlan?: DeviceWlan; - /** The device battery. */ - battery?: DeviceBattery; - /** The device state. */ - state?: DeviceState; - /** The device mode. */ - mode?: DeviceMode; - /** The device error. */ - error?: DeviceError; - /** The device fan speed. */ - fanSpeed?: DeviceFanSpeed; - /** The device water level. */ - waterLevel?: DeviceWaterLevel; - /** whether the device has a mop attached. */ - hasMopAttached?: boolean; - /** Whether the device has a waiting map. */ - hasWaitingMap?: boolean; -} - -/** Describes a device. */ -export class Device extends Entity { - /** Returns the device system. */ - get system(): DeviceSystem { - return this.props.system; - } - - /** Returns the device version. */ - get version(): DeviceVersion { - return this.props.version; - } - - /** Returns the device settings. */ - get config(): DeviceSettings | undefined { - return this.props.config; - } - - /** Returns the device current clean. */ - get currentClean(): DeviceCleanWork | undefined { - return this.props.currentClean; - } - - /** Returns the device orders. */ - get orders(): DeviceOrder[] | undefined { - return this.props.orders; - } - - /** Returns the device consumables. */ - get consumables(): DeviceConsumable[] | undefined { - return this.props.consumables; - } - - /** Returns the device map. */ - get map(): DeviceMap | undefined { - return this.props.map; - } - - /** Returns the device wlan. */ - get wlan(): DeviceWlan | undefined { - return this.props.wlan; - } - - /** Returns the device battery. */ - get battery(): DeviceBattery | undefined { - return this.props.battery; - } - - /** Returns the device state. */ - get state(): DeviceState | undefined { - return this.props.state; - } - - /** Returns the device mode. */ - get mode(): DeviceMode | undefined { - return this.props.mode; - } - - /** Returns the device error. */ - get error(): DeviceError | undefined { - return this.props.error; - } - - /** Returns the device fan speed. */ - get fanSpeed(): DeviceFanSpeed | undefined { - return this.props.fanSpeed; - } - - /** Returns the device water level. */ - get waterLevel(): DeviceWaterLevel | undefined { - return this.props.waterLevel; - } - - /** Returns whether the device has a mop attached. */ - get hasMopAttached(): boolean { - return Boolean(this.props.hasMopAttached); - } - - /** Returns whether the device has a waiting map. */ - get hasWaitingMap(): boolean { - return Boolean(this.props.hasWaitingMap); - } - - /** Updates the device system. */ - updateSystem(system: DeviceSystem): void { - this.validateDefinedProp({ system }, 'system'); - this.validateInstanceProp({ system }, 'system', DeviceSystem); - this.props.system = system; - } - - /** Updates the device version. */ - updateVersion(version: DeviceVersion): void { - this.validateDefinedProp({ version }, 'version'); - this.validateInstanceProp({ version }, 'version', DeviceVersion); - this.props.version = version; - } - - /** Updates the device settings. */ - updateConfig(config?: DeviceSettings): void { - this.validateInstanceProp({ config }, 'config', DeviceSettings); - this.props.config = config; - } - - /** Updates the device current clean. */ - updateCurrentClean(currentClean?: DeviceCleanWork): void { - this.validateInstanceProp({ currentClean }, 'currentClean', DeviceCleanWork); - this.props.currentClean = currentClean; - } - - /** Updates the device orders. */ - updateOrders(orders?: DeviceOrder[]): void { - this.validateArrayProp({ orders }, 'orders', DeviceOrder); - this.props.orders = orders; - } - - /** Updates the device consumables. */ - updateConsumables(consumables?: DeviceConsumable[]): void { - this.validateArrayProp({ consumables }, 'consumables', DeviceConsumable); - this.props.consumables = consumables; - } - - /** Updates the device map. */ - updateMap(map?: DeviceMap): void { - this.validateInstanceProp({ map }, 'map', DeviceMap); - this.props.map = map; - } - - /** Updates the device wlan. */ - updateWlan(wlan?: DeviceWlan): void { - this.validateInstanceProp({ wlan }, 'wlan', DeviceWlan); - this.props.wlan = wlan; - } - - /** Updates the device battery. */ - updateBattery(battery?: DeviceBattery): void { - this.validateInstanceProp({ battery }, 'battery', DeviceBattery); - this.props.battery = battery; - } - - /** Updates the device state. */ - updateState(state?: DeviceState): void { - this.validateInstanceProp({ state }, 'state', DeviceState); - this.props.state = state; - } - - /** Updates the device mode. */ - updateMode(mode?: DeviceMode): void { - this.validateInstanceProp({ mode }, 'mode', DeviceMode); - this.props.mode = mode; - } - - /** Updates the device error. */ - updateError(error?: DeviceError): void { - this.validateInstanceProp({ error }, 'error', DeviceError); - this.props.error = error; - } - - /** Updates the device fan speed. */ - updateFanSpeed(fanSpeed?: DeviceFanSpeed): void { - this.validateInstanceProp({ fanSpeed }, 'fanSpeed', DeviceFanSpeed); - this.props.fanSpeed = fanSpeed; - } - - /** Updates the device water level. */ - updateWaterLevel(waterLevel?: DeviceWaterLevel): void { - this.validateInstanceProp({ waterLevel }, 'waterLevel', DeviceWaterLevel); - this.props.waterLevel = waterLevel; - } - - /** Updates whether the device has a mop attached. */ - updateHasMopAttached(value: boolean): void { - this.validateTypeProp({ hasMopAttached: value }, 'hasMopAttached', 'boolean'); - this.props.hasMopAttached = value; - } - - /** Updates whether the device has a waiting map. */ - updateHasWaitingMap(value: boolean): void { - this.validateTypeProp({ hasWaitingMap: value }, 'hasWaitingMap', 'boolean'); - this.props.hasWaitingMap = value; - } - - protected validate(props: DeviceProps): void { - const keys: (keyof DeviceProps)[] = ['system', 'version']; - - keys.forEach((prop) => { - this.validateDefinedProp(props, prop); - }); - - this.validateInstanceProp(props, 'system', DeviceSystem); - this.validateInstanceProp(props, 'version', DeviceVersion); - this.validateInstanceProp(props, 'config', DeviceSettings); - this.validateInstanceProp(props, 'currentClean', DeviceCleanWork); - this.validateArrayProp(props, 'orders', DeviceOrder); - this.validateArrayProp(props, 'consumables', DeviceConsumable); - this.validateInstanceProp(props, 'map', DeviceMap); - this.validateInstanceProp(props, 'wlan', DeviceWlan); - this.validateInstanceProp(props, 'battery', DeviceBattery); - this.validateInstanceProp(props, 'state', DeviceState); - this.validateInstanceProp(props, 'mode', DeviceMode); - this.validateInstanceProp(props, 'error', DeviceError); - this.validateInstanceProp(props, 'fanSpeed', DeviceFanSpeed); - this.validateInstanceProp(props, 'waterLevel', DeviceWaterLevel); - this.validateTypeProp(props, 'hasMopAttached', 'boolean'); - this.validateTypeProp(props, 'hasWaitingMap', 'boolean'); - } -} diff --git a/packages/domain/src/entities/user.entity.test.ts b/packages/domain/src/entities/user.entity.test.ts deleted file mode 100644 index 988f9de0..00000000 --- a/packages/domain/src/entities/user.entity.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Entity } from '@agnoc/toolkit'; -import { expect } from 'chai'; -import { givenSomeUserProps } from '../test-support'; -import { User } from './user.entity'; - -describe('User', function () { - it('should be created', function () { - const userProps = givenSomeUserProps(); - const user = new User(userProps); - - expect(user).to.be.instanceOf(Entity); - expect(user.id).to.be.equal(userProps.id); - }); -}); diff --git a/packages/domain/src/entities/user.entity.ts b/packages/domain/src/entities/user.entity.ts deleted file mode 100644 index 31fdec4a..00000000 --- a/packages/domain/src/entities/user.entity.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Entity } from '@agnoc/toolkit'; -import type { EntityProps } from '@agnoc/toolkit'; - -/** Describes the user properties. */ -export type UserProps = EntityProps; - -/** Describes a user. */ -export class User extends Entity { - protected validate(_: EntityProps): void { - // noop - } -} diff --git a/packages/domain/src/event-buses/command-query.task-bus.test.ts b/packages/domain/src/event-buses/command-query.task-bus.test.ts new file mode 100644 index 00000000..c73eaf3b --- /dev/null +++ b/packages/domain/src/event-buses/command-query.task-bus.test.ts @@ -0,0 +1,15 @@ +import { TaskBus } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { CommandQueryBus } from './command-query.task-bus'; + +describe('CommandBus', function () { + let commandBus: CommandQueryBus; + + beforeEach(function () { + commandBus = new CommandQueryBus(); + }); + + it('should be created', function () { + expect(commandBus).to.be.instanceOf(TaskBus); + }); +}); diff --git a/packages/domain/src/event-buses/command-query.task-bus.ts b/packages/domain/src/event-buses/command-query.task-bus.ts new file mode 100644 index 00000000..235c6132 --- /dev/null +++ b/packages/domain/src/event-buses/command-query.task-bus.ts @@ -0,0 +1,8 @@ +import { TaskBus } from '@agnoc/toolkit'; +import type { Commands } from '../commands/commands'; +import type { Queries } from '../queries/queries'; + +export type CommandsOrQueries = Commands & Queries; +export type CommandOrQueryNames = keyof CommandsOrQueries; + +export class CommandQueryBus extends TaskBus {} diff --git a/packages/domain/src/event-buses/domain.event-bus.test.ts b/packages/domain/src/event-buses/domain.event-bus.test.ts new file mode 100644 index 00000000..1bb4872a --- /dev/null +++ b/packages/domain/src/event-buses/domain.event-bus.test.ts @@ -0,0 +1,15 @@ +import { EventBus } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { DomainEventBus } from './domain.event-bus'; + +describe('DomainEventBus', function () { + let domainEventBus: DomainEventBus; + + beforeEach(function () { + domainEventBus = new DomainEventBus(); + }); + + it('should be created', function () { + expect(domainEventBus).to.be.instanceOf(EventBus); + }); +}); diff --git a/packages/domain/src/event-buses/domain.event-bus.ts b/packages/domain/src/event-buses/domain.event-bus.ts new file mode 100644 index 00000000..4d2754a6 --- /dev/null +++ b/packages/domain/src/event-buses/domain.event-bus.ts @@ -0,0 +1,5 @@ +import { EventBus } from '@agnoc/toolkit'; +import type { DomainEventNames, DomainEvents } from '../domain-events/domain-events'; + +export type DomainEventBusEvents = { [Name in DomainEventNames]: DomainEvents[Name] }; +export class DomainEventBus extends EventBus {} diff --git a/packages/domain/src/event-handlers/command.task-handler.ts b/packages/domain/src/event-handlers/command.task-handler.ts new file mode 100644 index 00000000..17c3d480 --- /dev/null +++ b/packages/domain/src/event-handlers/command.task-handler.ts @@ -0,0 +1,7 @@ +import type { CommandNames, Commands } from '../commands/commands'; +import type { TaskHandler } from '@agnoc/toolkit'; + +export abstract class CommandHandler implements TaskHandler { + abstract forName: CommandNames; + abstract handle(event: Commands[this['forName']]): void; +} diff --git a/packages/domain/src/event-handlers/domain.event-handler.ts b/packages/domain/src/event-handlers/domain.event-handler.ts new file mode 100644 index 00000000..b37a4920 --- /dev/null +++ b/packages/domain/src/event-handlers/domain.event-handler.ts @@ -0,0 +1,11 @@ +import type { DomainEventNames, DomainEvents } from '../domain-events/domain-events'; +import type { EventHandler } from '@agnoc/toolkit'; + +/** Base class for domain event handlers. */ +export abstract class DomainEventHandler implements EventHandler { + /** The name of the event to listen to. */ + abstract forName: DomainEventNames; + + /** Handle the event. */ + abstract handle(event: DomainEvents[this['forName']]): void; +} diff --git a/packages/domain/src/event-handlers/query.task-handler.ts b/packages/domain/src/event-handlers/query.task-handler.ts new file mode 100644 index 00000000..d306da8a --- /dev/null +++ b/packages/domain/src/event-handlers/query.task-handler.ts @@ -0,0 +1,7 @@ +import type { Queries, QueryNames } from '../queries/queries'; +import type { TaskHandler } from '@agnoc/toolkit'; + +export abstract class QueryHandler implements TaskHandler { + abstract forName: QueryNames; + abstract handle(event: Queries[this['forName']]): void; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index de5aed10..9137afd0 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,3 +1,39 @@ +export * from './aggregate-roots/connection.aggregate-root'; +export * from './aggregate-roots/device.aggregate-root'; +export * from './commands/clean-spot.command'; +export * from './commands/clean-zones.command'; +export * from './commands/commands'; +export * from './commands/locate-device.command'; +export * from './commands/pause-cleaning.command'; +export * from './commands/reset-consumable.command'; +export * from './commands/return-home.command'; +export * from './commands/set-carpet-mode.command'; +export * from './commands/set-device-mode.command'; +export * from './commands/set-device-quiet-hours.command'; +export * from './commands/set-device-voice.command'; +export * from './commands/set-fan-speed.command'; +export * from './commands/set-water-level.command'; +export * from './commands/start-cleaning.command'; +export * from './commands/stop-cleaning.command'; +export * from './domain-events/connection-device-changed.domain-event'; +export * from './domain-events/device-battery-changed.domain-event'; +export * from './domain-events/device-clean-work-changed.domain-event'; +export * from './domain-events/device-connected.domain-event'; +export * from './domain-events/device-created.domain-event'; +export * from './domain-events/device-error-changed.domain-event'; +export * from './domain-events/device-fan-speed-changed.domain-event'; +export * from './domain-events/device-locked.domain-event'; +export * from './domain-events/device-map-changed.domain-event'; +export * from './domain-events/device-map-pending.domain-event'; +export * from './domain-events/device-mode-changed.domain-event'; +export * from './domain-events/device-mop-attached.domain-event'; +export * from './domain-events/device-network-changed.domain-event'; +export * from './domain-events/device-orders-changed.domain-event'; +export * from './domain-events/device-settings-changed.domain-event'; +export * from './domain-events/device-state-changed.domain-event'; +export * from './domain-events/device-version-changed.domain-event'; +export * from './domain-events/device-water-level-changed.domain-event'; +export * from './domain-events/domain-events'; export * from './domain-primitives/clean-mode.domain-primitive'; export * from './domain-primitives/clean-size.domain-primitive'; export * from './domain-primitives/device-battery.domain-primitive'; @@ -9,18 +45,27 @@ export * from './domain-primitives/device-water-level.domain-primitive'; export * from './domain-primitives/week-day.domain-primitive'; export * from './entities/device-map.entity'; export * from './entities/device-order.entity'; -export * from './entities/device.entity'; export * from './entities/room.entity'; -export * from './entities/user.entity'; export * from './entities/zone.entity'; +export * from './event-buses/command-query.task-bus'; +export * from './event-buses/domain.event-bus'; +export * from './event-handlers/command.task-handler'; +export * from './event-handlers/domain.event-handler'; +export * from './event-handlers/query.task-handler'; +export * from './queries/find-device.query'; +export * from './queries/find-devices.query'; +export * from './queries/get-device-consumables.query'; +export * from './queries/queries'; +export * from './repositories/connection.repository'; +export * from './repositories/device.repository'; export * from './value-objects/device-clean-work.value-object'; export * from './value-objects/device-consumable.value-object'; +export * from './value-objects/device-network.value-object'; export * from './value-objects/device-setting.value-object'; export * from './value-objects/device-settings.value-object'; export * from './value-objects/device-system.value-object'; export * from './value-objects/device-time.value-object'; export * from './value-objects/device-version.value-object'; -export * from './value-objects/device-wlan.value-object'; export * from './value-objects/map-coordinate.value-object'; export * from './value-objects/map-pixel.value-object'; export * from './value-objects/map-position.value-object'; diff --git a/packages/domain/src/queries/find-device.query.test.ts b/packages/domain/src/queries/find-device.query.test.ts new file mode 100644 index 00000000..6c127fe4 --- /dev/null +++ b/packages/domain/src/queries/find-device.query.test.ts @@ -0,0 +1,60 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, ID, Query } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { Device } from '../aggregate-roots/device.aggregate-root'; +import { givenSomeDeviceProps } from '../test-support'; +import { FindDeviceQuery } from './find-device.query'; +import type { FindDeviceQueryInput, FindDeviceQueryOutput } from './find-device.query'; + +describe('FindDeviceQuery', function () { + it('should be created', function () { + const input = givenAFindDeviceQueryInput(); + const query = new FindDeviceQuery(input); + + expect(query).to.be.instanceOf(Query); + expect(query.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + // @ts-expect-error - missing property + expect(() => new FindDeviceQuery({ ...givenAFindDeviceQueryInput(), deviceId: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'deviceId' for FindDeviceQuery not provided`, + ); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + // @ts-expect-error - invalid property + expect(() => new FindDeviceQuery({ ...givenAFindDeviceQueryInput(), deviceId: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of FindDeviceQuery is not an instance of ID`, + ); + }); + + it("should throw an error when 'device' is not provided", function () { + const query = new FindDeviceQuery(givenAFindDeviceQueryInput()); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAFindDeviceQueryOutput(), device: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'device' for FindDeviceQuery not provided`, + ); + }); + + it("should throw an error when 'device' is not a Device", function () { + const query = new FindDeviceQuery(givenAFindDeviceQueryInput()); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAFindDeviceQueryOutput(), device: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'device' of FindDeviceQuery is not an instance of Device`, + ); + }); +}); + +function givenAFindDeviceQueryInput(): FindDeviceQueryInput { + return { deviceId: ID.generate() }; +} + +function givenAFindDeviceQueryOutput(): FindDeviceQueryOutput { + return { device: new Device(givenSomeDeviceProps()) }; +} diff --git a/packages/domain/src/queries/find-device.query.ts b/packages/domain/src/queries/find-device.query.ts new file mode 100644 index 00000000..1b49261d --- /dev/null +++ b/packages/domain/src/queries/find-device.query.ts @@ -0,0 +1,26 @@ +import { ID, Query } from '@agnoc/toolkit'; +import { Device } from '../aggregate-roots/device.aggregate-root'; + +export interface FindDeviceQueryInput { + deviceId: ID; +} + +export interface FindDeviceQueryOutput { + device: Device; +} + +export class FindDeviceQuery extends Query { + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: FindDeviceQueryInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } + + override validateOutput(output: FindDeviceQueryOutput): void { + this.validateDefinedProp(output, 'device'); + this.validateInstanceProp(output, 'device', Device); + } +} diff --git a/packages/domain/src/queries/find-devices.query.test.ts b/packages/domain/src/queries/find-devices.query.test.ts new file mode 100644 index 00000000..cb6e9c60 --- /dev/null +++ b/packages/domain/src/queries/find-devices.query.test.ts @@ -0,0 +1,48 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, Query } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { Device } from '../aggregate-roots/device.aggregate-root'; +import { givenSomeDeviceProps } from '../test-support'; +import { FindDevicesQuery } from './find-devices.query'; +import type { FindDevicesQueryOutput } from './find-devices.query'; + +describe('FindDevicesQuery', function () { + it('should be created', function () { + const query = new FindDevicesQuery(); + + expect(query).to.be.instanceOf(Query); + }); + + it("should throw an error when 'devices' is not provided", function () { + const query = new FindDevicesQuery(); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAFindDevicesQueryOutput(), devices: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'devices' for FindDevicesQuery not provided`, + ); + }); + + it("should throw an error when 'devices' is not an array", function () { + const query = new FindDevicesQuery(); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAFindDevicesQueryOutput(), devices: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'devices' of FindDevicesQuery is not an array`, + ); + }); + + it("should throw an error when 'devices' is not an array of Devices", function () { + const query = new FindDevicesQuery(); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAFindDevicesQueryOutput(), devices: ['foo'] })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'devices' of FindDevicesQuery is not an array of Device`, + ); + }); +}); + +function givenAFindDevicesQueryOutput(): FindDevicesQueryOutput { + return { devices: [new Device(givenSomeDeviceProps())] }; +} diff --git a/packages/domain/src/queries/find-devices.query.ts b/packages/domain/src/queries/find-devices.query.ts new file mode 100644 index 00000000..28740c62 --- /dev/null +++ b/packages/domain/src/queries/find-devices.query.ts @@ -0,0 +1,17 @@ +import { Query } from '@agnoc/toolkit'; +import { Device } from '../aggregate-roots/device.aggregate-root'; + +export interface FindDevicesQueryOutput { + devices: Device[]; +} + +export class FindDevicesQuery extends Query { + protected validate(): void { + // noop + } + + override validateOutput(output: FindDevicesQueryOutput): void { + this.validateDefinedProp(output, 'devices'); + this.validateArrayProp(output, 'devices', Device); + } +} diff --git a/packages/domain/src/queries/get-device-consumables.query.test.ts b/packages/domain/src/queries/get-device-consumables.query.test.ts new file mode 100644 index 00000000..b0887271 --- /dev/null +++ b/packages/domain/src/queries/get-device-consumables.query.test.ts @@ -0,0 +1,71 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, ID, Query } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceConsumableProps } from '../test-support'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; +import { GetDeviceConsumablesQuery } from './get-device-consumables.query'; +import type { GetDeviceConsumablesQueryInput, GetDeviceConsumablesQueryOutput } from './get-device-consumables.query'; + +describe('GetDeviceConsumablesQuery', function () { + it('should be created', function () { + const input = givenAGetDeviceConsumablesQueryInput(); + const query = new GetDeviceConsumablesQuery(input); + + expect(query).to.be.instanceOf(Query); + expect(query.deviceId).to.be.equal(input.deviceId); + }); + + it("should throw an error when 'deviceId' is not provided", function () { + expect( + // @ts-expect-error - missing property + () => new GetDeviceConsumablesQuery({ ...givenAGetDeviceConsumablesQueryInput(), deviceId: undefined }), + ).to.throw(ArgumentNotProvidedException, `Property 'deviceId' for GetDeviceConsumablesQuery not provided`); + }); + + it("should throw an error when 'deviceId' is not an ID", function () { + expect( + // @ts-expect-error - invalid property + () => new GetDeviceConsumablesQuery({ ...givenAGetDeviceConsumablesQueryInput(), deviceId: 'foo' }), + ).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'deviceId' of GetDeviceConsumablesQuery is not an instance of ID`, + ); + }); + + it("should throw an error when 'consumables' is not provided", function () { + const query = new GetDeviceConsumablesQuery(givenAGetDeviceConsumablesQueryInput()); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAGetDeviceConsumablesQueryOutput(), consumables: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'consumables' for GetDeviceConsumablesQuery not provided`, + ); + }); + + it("should throw an error when 'consumables' is not an array", function () { + const query = new GetDeviceConsumablesQuery(givenAGetDeviceConsumablesQueryInput()); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAGetDeviceConsumablesQueryOutput(), consumables: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'consumables' of GetDeviceConsumablesQuery is not an array`, + ); + }); + + it("should throw an error when 'consumables' is not an array of DeviceConsumable", function () { + const query = new GetDeviceConsumablesQuery(givenAGetDeviceConsumablesQueryInput()); + + // @ts-expect-error - missing property + expect(() => query.validateOutput({ ...givenAGetDeviceConsumablesQueryOutput(), consumables: ['foo'] })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'consumables' of GetDeviceConsumablesQuery is not an array of DeviceConsumable`, + ); + }); +}); + +function givenAGetDeviceConsumablesQueryInput(): GetDeviceConsumablesQueryInput { + return { deviceId: ID.generate() }; +} + +function givenAGetDeviceConsumablesQueryOutput(): GetDeviceConsumablesQueryOutput { + return { consumables: [new DeviceConsumable(givenSomeDeviceConsumableProps())] }; +} diff --git a/packages/domain/src/queries/get-device-consumables.query.ts b/packages/domain/src/queries/get-device-consumables.query.ts new file mode 100644 index 00000000..6c70d696 --- /dev/null +++ b/packages/domain/src/queries/get-device-consumables.query.ts @@ -0,0 +1,26 @@ +import { ID, Query } from '@agnoc/toolkit'; +import { DeviceConsumable } from '../value-objects/device-consumable.value-object'; + +export interface GetDeviceConsumablesQueryInput { + deviceId: ID; +} + +export interface GetDeviceConsumablesQueryOutput { + consumables: DeviceConsumable[]; +} + +export class GetDeviceConsumablesQuery extends Query { + get deviceId(): ID { + return this.props.deviceId; + } + + protected validate(props: GetDeviceConsumablesQueryInput): void { + this.validateDefinedProp(props, 'deviceId'); + this.validateInstanceProp(props, 'deviceId', ID); + } + + override validateOutput(output: GetDeviceConsumablesQueryOutput): void { + this.validateDefinedProp(output, 'consumables'); + this.validateArrayProp(output, 'consumables', DeviceConsumable); + } +} diff --git a/packages/domain/src/queries/queries.ts b/packages/domain/src/queries/queries.ts new file mode 100644 index 00000000..39291a72 --- /dev/null +++ b/packages/domain/src/queries/queries.ts @@ -0,0 +1,14 @@ +import { FindDeviceQuery } from './find-device.query'; +import { FindDevicesQuery } from './find-devices.query'; +import { GetDeviceConsumablesQuery } from './get-device-consumables.query'; +import type { InstanceTypeProps } from '@agnoc/toolkit'; + +export const Queries = { + FindDeviceQuery, + FindDevicesQuery, + GetDeviceConsumablesQuery, +}; + +export type Queries = InstanceTypeProps; + +export type QueryNames = keyof Queries; diff --git a/packages/domain/src/repositories/connection.repository.test.ts b/packages/domain/src/repositories/connection.repository.test.ts new file mode 100644 index 00000000..25d85be1 --- /dev/null +++ b/packages/domain/src/repositories/connection.repository.test.ts @@ -0,0 +1,52 @@ +import { ID, Repository } from '@agnoc/toolkit'; +import { imock, instance, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { Connection } from '../aggregate-roots/connection.aggregate-root'; +import { Device } from '../aggregate-roots/device.aggregate-root'; +import { givenSomeConnectionProps, givenSomeDeviceProps } from '../test-support'; +import { ConnectionRepository } from './connection.repository'; +import type { ConnectionProps } from '../aggregate-roots/connection.aggregate-root'; +import type { Adapter, EventBus } from '@agnoc/toolkit'; + +describe('ConnectionRepository', function () { + let eventBus: EventBus; + let adapter: Adapter; + let repository: ConnectionRepository; + + beforeEach(function () { + eventBus = imock(); + adapter = imock(); + repository = new ConnectionRepository(instance(eventBus), instance(adapter)); + }); + + it('should be a repository', function () { + expect(repository).to.be.an.instanceOf(Repository); + }); + + describe('#findByDeviceId', function () { + it('should find a connection by device id', async function () { + const connections = [ + givenAConnectionWithDeviceId(new ID(1)), + givenAConnectionWithDeviceId(new ID(2)), + givenAConnectionWithDeviceId(new ID(3)), + ]; + + when(adapter.getAll()).thenReturn(connections); + + const ret = await repository.findByDeviceId(new ID(1)); + + expect(ret).to.contain(connections[0]); + }); + }); +}); + +class DummyConnection extends Connection { + connectionType = 'Dummy'; +} + +function givenAConnectionWithDeviceId(deviceId: ID) { + return new DummyConnection({ + ...givenSomeConnectionProps(), + device: new Device({ ...givenSomeDeviceProps(), id: deviceId }), + }); +} diff --git a/packages/domain/src/repositories/connection.repository.ts b/packages/domain/src/repositories/connection.repository.ts new file mode 100644 index 00000000..07160351 --- /dev/null +++ b/packages/domain/src/repositories/connection.repository.ts @@ -0,0 +1,16 @@ +import { Repository } from '@agnoc/toolkit'; +import type { Connection, ConnectionWithDevice } from '../aggregate-roots/connection.aggregate-root'; +import type { ID } from '@agnoc/toolkit'; + +export interface ConnectionRepositoryPorts { + findByDeviceId(deviceId: ID): Promise; +} + +export class ConnectionRepository extends Repository implements ConnectionRepositoryPorts { + async findByDeviceId(deviceId: ID): Promise<(Connection & ConnectionWithDevice)[]> { + const connections = this.adapter.getAll() as Connection[]; + + return connections.filter((connection) => deviceId.equals(connection.device?.id)) as (Connection & + ConnectionWithDevice)[]; + } +} diff --git a/packages/domain/src/repositories/device.repository.test.ts b/packages/domain/src/repositories/device.repository.test.ts new file mode 100644 index 00000000..987328da --- /dev/null +++ b/packages/domain/src/repositories/device.repository.test.ts @@ -0,0 +1,21 @@ +import { Repository } from '@agnoc/toolkit'; +import { imock, instance } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { DeviceRepository } from './device.repository'; +import type { Adapter, EventBus } from '@agnoc/toolkit'; + +describe('DeviceRepository', function () { + let eventBus: EventBus; + let adapter: Adapter; + let repository: DeviceRepository; + + beforeEach(function () { + eventBus = imock(); + adapter = imock(); + repository = new DeviceRepository(instance(eventBus), instance(adapter)); + }); + + it('should be a repository', function () { + expect(repository).to.be.an.instanceOf(Repository); + }); +}); diff --git a/packages/domain/src/repositories/device.repository.ts b/packages/domain/src/repositories/device.repository.ts new file mode 100644 index 00000000..12db51dd --- /dev/null +++ b/packages/domain/src/repositories/device.repository.ts @@ -0,0 +1,4 @@ +import { Repository } from '@agnoc/toolkit'; +import type { Device } from '../aggregate-roots/device.aggregate-root'; + +export class DeviceRepository extends Repository {} diff --git a/packages/domain/src/test-support.ts b/packages/domain/src/test-support.ts index 07509766..11cb6cd3 100644 --- a/packages/domain/src/test-support.ts +++ b/packages/domain/src/test-support.ts @@ -1,6 +1,7 @@ import { ID } from '@agnoc/toolkit'; import { CleanMode, CleanModeValue } from './domain-primitives/clean-mode.domain-primitive'; import { CleanSize } from './domain-primitives/clean-size.domain-primitive'; +import { DeviceBattery } from './domain-primitives/device-battery.domain-primitive'; import { DeviceFanSpeed, DeviceFanSpeedValue } from './domain-primitives/device-fan-speed.domain-primitive'; import { DeviceWaterLevel, DeviceWaterLevelValue } from './domain-primitives/device-water-level.domain-primitive'; import { WeekDay, WeekDayValue } from './domain-primitives/week-day.domain-primitive'; @@ -15,20 +16,20 @@ import { MapCoordinate } from './value-objects/map-coordinate.value-object'; import { MapPixel } from './value-objects/map-pixel.value-object'; import { QuietHoursSetting } from './value-objects/quiet-hours-setting.value-object'; import { VoiceSetting } from './value-objects/voice-setting.value-object'; +import type { ConnectionProps } from './aggregate-roots/connection.aggregate-root'; +import type { DeviceProps } from './aggregate-roots/device.aggregate-root'; import type { DeviceMapProps } from './entities/device-map.entity'; import type { DeviceOrderProps } from './entities/device-order.entity'; -import type { DeviceProps } from './entities/device.entity'; import type { RoomProps } from './entities/room.entity'; -import type { UserProps } from './entities/user.entity'; import type { ZoneProps } from './entities/zone.entity'; import type { DeviceCleanWorkProps } from './value-objects/device-clean-work.value-object'; import type { DeviceConsumableProps } from './value-objects/device-consumable.value-object'; +import type { DeviceNetworkProps } from './value-objects/device-network.value-object'; import type { DeviceSettingProps } from './value-objects/device-setting.value-object'; import type { DeviceSettingsProps } from './value-objects/device-settings.value-object'; import type { DeviceSystemProps } from './value-objects/device-system.value-object'; import type { DeviceTimeProps } from './value-objects/device-time.value-object'; import type { DeviceVersionProps } from './value-objects/device-version.value-object'; -import type { DeviceWlanProps } from './value-objects/device-wlan.value-object'; import type { MapCoordinateProps } from './value-objects/map-coordinate.value-object'; import type { MapPixelProps } from './value-objects/map-pixel.value-object'; import type { MapPositionProps } from './value-objects/map-position.value-object'; @@ -74,7 +75,7 @@ export function givenSomeDeviceCleanWorkProps(): DeviceCleanWorkProps { export function givenSomeDeviceConsumableProps(): DeviceConsumableProps { return { type: DeviceConsumableType.MainBrush, - minutesUsed: 1, + hoursUsed: 1, }; } @@ -118,7 +119,7 @@ export function givenSomeDeviceVersionProps(): DeviceVersionProps { }; } -export function givenSomeDeviceWlanProps(): DeviceWlanProps { +export function givenSomeDeviceNetworkProps(): DeviceNetworkProps { return { ipv4: '127.0.0.1', ssid: 'ssid', @@ -159,12 +160,6 @@ export function givenSomeDeviceMapProps(): DeviceMapProps { }; } -export function givenSomeUserProps(): UserProps { - return { - id: ID.generate(), - }; -} - export function givenSomeDeviceOrderProps(): DeviceOrderProps { return { id: ID.generate(), @@ -184,13 +179,24 @@ export function givenSomeDeviceOrderProps(): DeviceOrderProps { export function givenSomeDeviceSystemProps(): DeviceSystemProps { return { type: 9, + serialNumber: 'serialNumber', }; } export function givenSomeDeviceProps(): DeviceProps { return { id: ID.generate(), + userId: ID.generate(), system: new DeviceSystem(givenSomeDeviceSystemProps()), version: new DeviceVersion(givenSomeDeviceVersionProps()), + battery: new DeviceBattery(100), + isConnected: false, + isLocked: false, + }; +} + +export function givenSomeConnectionProps(): ConnectionProps { + return { + id: ID.generate(), }; } diff --git a/packages/domain/src/value-objects/device-consumable.value-object.test.ts b/packages/domain/src/value-objects/device-consumable.value-object.test.ts index d7a4ce23..91ee3ff9 100644 --- a/packages/domain/src/value-objects/device-consumable.value-object.test.ts +++ b/packages/domain/src/value-objects/device-consumable.value-object.test.ts @@ -9,7 +9,7 @@ describe('DeviceConsumable', function () { const deviceConsumable = new DeviceConsumable(deviceConsumableProps); expect(deviceConsumable.type).to.be.equal(deviceConsumableProps.type); - expect(deviceConsumable.minutesUsed).to.be.equal(deviceConsumableProps.minutesUsed); + expect(deviceConsumable.hoursUsed).to.be.equal(deviceConsumableProps.hoursUsed); }); it("should throw an error when 'type' property is not provided", function () { @@ -28,19 +28,19 @@ describe('DeviceConsumable', function () { ); }); - it("should throw an error when 'minutesUsed' property is not provided", function () { + it("should throw an error when 'hoursUsed' property is not provided", function () { // @ts-expect-error - missing property - expect(() => new DeviceConsumable({ ...givenSomeDeviceConsumableProps(), minutesUsed: undefined })).to.throw( + expect(() => new DeviceConsumable({ ...givenSomeDeviceConsumableProps(), hoursUsed: undefined })).to.throw( ArgumentNotProvidedException, - `Property 'minutesUsed' for DeviceConsumable not provided`, + `Property 'hoursUsed' for DeviceConsumable not provided`, ); }); - it("should throw an error when 'minutesUsed' property is invalid", function () { + it("should throw an error when 'hoursUsed' property is invalid", function () { // @ts-expect-error - invalid property - expect(() => new DeviceConsumable({ ...givenSomeDeviceConsumableProps(), minutesUsed: 'foo' })).to.throw( + expect(() => new DeviceConsumable({ ...givenSomeDeviceConsumableProps(), hoursUsed: 'foo' })).to.throw( ArgumentInvalidException, - `Value 'foo' for property 'minutesUsed' of DeviceConsumable is not a number`, + `Value 'foo' for property 'hoursUsed' of DeviceConsumable is not a number`, ); }); }); diff --git a/packages/domain/src/value-objects/device-consumable.value-object.ts b/packages/domain/src/value-objects/device-consumable.value-object.ts index 6c44d9b5..bfd7e6d2 100644 --- a/packages/domain/src/value-objects/device-consumable.value-object.ts +++ b/packages/domain/src/value-objects/device-consumable.value-object.ts @@ -13,7 +13,7 @@ export interface DeviceConsumableProps { /** The type of the consumable. */ type: DeviceConsumableType; /** The minutes used of the consumable. */ - minutesUsed: number; + hoursUsed: number; } /** Describe a device consumable. */ @@ -23,17 +23,17 @@ export class DeviceConsumable extends ValueObject { return this.props.type; } - /** Returns the minutes used of the consumable. */ - get minutesUsed(): number { - return this.props.minutesUsed; + /** Returns the hours used of the consumable. */ + get hoursUsed(): number { + return this.props.hoursUsed; } protected validate(props: DeviceConsumableProps): void { - const keys: (keyof DeviceConsumableProps)[] = ['type', 'minutesUsed']; + const keys: (keyof DeviceConsumableProps)[] = ['type', 'hoursUsed']; keys.forEach((prop) => this.validateDefinedProp(props, prop)); this.validateListProp(props, 'type', Object.values(DeviceConsumableType)); - this.validateNumberProp(props, 'minutesUsed'); + this.validateNumberProp(props, 'hoursUsed'); } } diff --git a/packages/domain/src/value-objects/device-network.value-object.test.ts b/packages/domain/src/value-objects/device-network.value-object.test.ts new file mode 100644 index 00000000..aee22c2b --- /dev/null +++ b/packages/domain/src/value-objects/device-network.value-object.test.ts @@ -0,0 +1,98 @@ +import { ArgumentInvalidException, ArgumentNotProvidedException, ValueObject } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { givenSomeDeviceNetworkProps } from '../test-support'; +import { DeviceNetwork } from './device-network.value-object'; + +describe('DeviceNetwork', function () { + it('should be created', function () { + const deviceNetworkProps = givenSomeDeviceNetworkProps(); + const deviceNetwork = new DeviceNetwork(deviceNetworkProps); + + expect(deviceNetwork).to.be.instanceOf(ValueObject); + expect(deviceNetwork.ipv4).to.be.equal(deviceNetworkProps.ipv4); + expect(deviceNetwork.ssid).to.be.equal(deviceNetworkProps.ssid); + expect(deviceNetwork.port).to.be.equal(deviceNetworkProps.port); + expect(deviceNetwork.mask).to.be.equal(deviceNetworkProps.mask); + expect(deviceNetwork.mac).to.be.equal(deviceNetworkProps.mac); + }); + + it("should throw an error when 'ipv4' property is not provided", function () { + // @ts-expect-error - missing property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), ipv4: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'ipv4' for DeviceNetwork not provided`, + ); + }); + + it("should throw an error when 'ipv4' property is invalid", function () { + // @ts-expect-error - invalid property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), ipv4: 1 })).to.throw( + ArgumentInvalidException, + `Value '1' for property 'ipv4' of DeviceNetwork is not a string`, + ); + }); + + it("should throw an error when 'ssid' property is not provided", function () { + // @ts-expect-error - missing property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), ssid: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'ssid' for DeviceNetwork not provided`, + ); + }); + + it("should throw an error when 'ssid' property is invalid", function () { + // @ts-expect-error - invalid property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), ssid: 1 })).to.throw( + ArgumentInvalidException, + `Value '1' for property 'ssid' of DeviceNetwork is not a string`, + ); + }); + + it("should throw an error when 'port' property is not provided", function () { + // @ts-expect-error - missing property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), port: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'port' for DeviceNetwork not provided`, + ); + }); + + it("should throw an error when 'port' property is invalid", function () { + // @ts-expect-error - invalid property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), port: 'foo' })).to.throw( + ArgumentInvalidException, + `Value 'foo' for property 'port' of DeviceNetwork is not a number`, + ); + }); + + it("should throw an error when 'mask' property is not provided", function () { + // @ts-expect-error - missing property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), mask: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'mask' for DeviceNetwork not provided`, + ); + }); + + it("should throw an error when 'mask' property is invalid", function () { + // @ts-expect-error - invalid property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), mask: 1 })).to.throw( + ArgumentInvalidException, + `Value '1' for property 'mask' of DeviceNetwork is not a string`, + ); + }); + + it("should throw an error when 'mac' property is not provided", function () { + // @ts-expect-error - missing property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), mac: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'mac' for DeviceNetwork not provided`, + ); + }); + + it("should throw an error when 'mac' property is invalid", function () { + // @ts-expect-error - invalid property + expect(() => new DeviceNetwork({ ...givenSomeDeviceNetworkProps(), mac: 1 })).to.throw( + ArgumentInvalidException, + `Value '1' for property 'mac' of DeviceNetwork is not a string`, + ); + }); +}); diff --git a/packages/domain/src/value-objects/device-wlan.value-object.ts b/packages/domain/src/value-objects/device-network.value-object.ts similarity index 74% rename from packages/domain/src/value-objects/device-wlan.value-object.ts rename to packages/domain/src/value-objects/device-network.value-object.ts index c808ecab..772e3b9c 100644 --- a/packages/domain/src/value-objects/device-wlan.value-object.ts +++ b/packages/domain/src/value-objects/device-network.value-object.ts @@ -1,7 +1,7 @@ import { ValueObject } from '@agnoc/toolkit'; -/** The properties of the device wlan. */ -export interface DeviceWlanProps { +/** The properties of the device network. */ +export interface DeviceNetworkProps { /** The ipv4 address. */ ipv4: string; /** The ssid. */ @@ -14,8 +14,8 @@ export interface DeviceWlanProps { mac: string; } -/** Describe the wlan of a device. */ -export class DeviceWlan extends ValueObject { +/** Describe the network of a device. */ +export class DeviceNetwork extends ValueObject { /** Returns the ipv4 address. */ get ipv4(): string { return this.props.ipv4; @@ -41,8 +41,8 @@ export class DeviceWlan extends ValueObject { return this.props.mac; } - protected validate(props: DeviceWlanProps): void { - const keys: (keyof DeviceWlanProps)[] = ['ipv4', 'ssid', 'port', 'mask', 'mac']; + protected validate(props: DeviceNetworkProps): void { + const keys: (keyof DeviceNetworkProps)[] = ['ipv4', 'ssid', 'port', 'mask', 'mac']; keys.forEach((prop) => { this.validateDefinedProp(props, prop); diff --git a/packages/domain/src/value-objects/device-system.value-object.test.ts b/packages/domain/src/value-objects/device-system.value-object.test.ts index 52afed15..12f37143 100644 --- a/packages/domain/src/value-objects/device-system.value-object.test.ts +++ b/packages/domain/src/value-objects/device-system.value-object.test.ts @@ -1,13 +1,16 @@ import { ArgumentInvalidException, ArgumentNotProvidedException } from '@agnoc/toolkit'; import { expect } from 'chai'; +import { givenSomeDeviceSystemProps } from '../test-support'; import { DeviceCapability, DeviceSystem } from './device-system.value-object'; describe('DeviceSystem', function () { it('should be created with a 3090 model', function () { - const deviceSystem = new DeviceSystem({ type: 3 }); + const deviceSystemProps = { ...givenSomeDeviceSystemProps(), type: 3 }; + const deviceSystem = new DeviceSystem(deviceSystemProps); expect(deviceSystem).to.be.instanceOf(DeviceSystem); expect(deviceSystem.type).to.be.equal(3); + expect(deviceSystem.serialNumber).to.be.equal(deviceSystemProps.serialNumber); expect(deviceSystem.model).to.be.equal('C3090'); expect(deviceSystem.supports(DeviceCapability.MAP_PLANS)).to.be.equal(false); expect(deviceSystem.supports(DeviceCapability.CONSUMABLES)).to.be.equal(false); @@ -15,10 +18,12 @@ describe('DeviceSystem', function () { }); it('should be created with a 3490 model', function () { - const deviceSystem = new DeviceSystem({ type: 9 }); + const deviceSystemProps = { ...givenSomeDeviceSystemProps(), type: 9 }; + const deviceSystem = new DeviceSystem(deviceSystemProps); expect(deviceSystem).to.be.instanceOf(DeviceSystem); expect(deviceSystem.type).to.be.equal(9); + expect(deviceSystem.serialNumber).to.be.equal(deviceSystemProps.serialNumber); expect(deviceSystem.model).to.be.equal('C3490'); expect(deviceSystem.supports(DeviceCapability.MAP_PLANS)).to.be.equal(true); expect(deviceSystem.supports(DeviceCapability.CONSUMABLES)).to.be.equal(true); @@ -26,7 +31,7 @@ describe('DeviceSystem', function () { }); it('should be created with an unknown model', function () { - const deviceSystem = new DeviceSystem({ type: -1 }); + const deviceSystem = new DeviceSystem({ ...givenSomeDeviceSystemProps(), type: -1 }); expect(deviceSystem).to.be.instanceOf(DeviceSystem); expect(deviceSystem.type).to.be.equal(-1); @@ -38,17 +43,33 @@ describe('DeviceSystem', function () { it("should throw an error when 'type' is not provided", function () { // @ts-expect-error - invalid value - expect(() => new DeviceSystem({ foo: 'bar' })).to.throw( + expect(() => new DeviceSystem({ ...givenSomeDeviceSystemProps(), type: undefined })).to.throw( ArgumentNotProvidedException, `Property 'type' for DeviceSystem not provided`, ); }); + it("should throw an error when 'serialNumber' is not provided", function () { + // @ts-expect-error - invalid value + expect(() => new DeviceSystem({ ...givenSomeDeviceSystemProps(), serialNumber: undefined })).to.throw( + ArgumentNotProvidedException, + `Property 'serialNumber' for DeviceSystem not provided`, + ); + }); + it("should throw an error when 'type' is not a number", function () { // @ts-expect-error - invalid value - expect(() => new DeviceSystem({ type: 'foo' })).to.throw( + expect(() => new DeviceSystem({ ...givenSomeDeviceSystemProps(), type: 'foo' })).to.throw( ArgumentInvalidException, `Value 'foo' for property 'type' of DeviceSystem is not a number`, ); }); + + it("should throw an error when 'serialNumber' is not a string", function () { + // @ts-expect-error - invalid value + expect(() => new DeviceSystem({ ...givenSomeDeviceSystemProps(), serialNumber: 1 })).to.throw( + ArgumentInvalidException, + `Value '1' for property 'serialNumber' of DeviceSystem is not a string`, + ); + }); }); diff --git a/packages/domain/src/value-objects/device-system.value-object.ts b/packages/domain/src/value-objects/device-system.value-object.ts index 228c9b22..850920cf 100644 --- a/packages/domain/src/value-objects/device-system.value-object.ts +++ b/packages/domain/src/value-objects/device-system.value-object.ts @@ -4,6 +4,7 @@ import type { ValueOf } from '@agnoc/toolkit'; /** Describes the device system properties. */ export interface DeviceSystemProps { type: number; + serialNumber: string; } /** Describes the device system. */ @@ -13,6 +14,11 @@ export class DeviceSystem extends ValueObject { return this.props.type; } + /** Returns the device serial number. */ + get serialNumber(): string { + return this.props.serialNumber; + } + /** Returns the device model. */ get model(): DeviceModel { return DeviceType[this.props.type as DeviceType] || DeviceModel.UNKNOWN; @@ -29,8 +35,14 @@ export class DeviceSystem extends ValueObject { } protected validate(props: DeviceSystemProps): void { - this.validateDefinedProp(props, 'type'); + const keys: (keyof DeviceSystemProps)[] = ['type', 'serialNumber']; + + keys.forEach((prop) => { + this.validateDefinedProp(props, prop); + }); + this.validateNumberProp(props, 'type'); + this.validateTypeProp(props, 'serialNumber', 'string'); } } diff --git a/packages/domain/src/value-objects/device-wlan.value-object.test.ts b/packages/domain/src/value-objects/device-wlan.value-object.test.ts deleted file mode 100644 index 3d5d55a3..00000000 --- a/packages/domain/src/value-objects/device-wlan.value-object.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ArgumentInvalidException, ArgumentNotProvidedException, ValueObject } from '@agnoc/toolkit'; -import { expect } from 'chai'; -import { givenSomeDeviceWlanProps } from '../test-support'; -import { DeviceWlan } from './device-wlan.value-object'; - -describe('DeviceWlan', function () { - it('should be created', function () { - const deviceWlanProps = givenSomeDeviceWlanProps(); - const deviceWlan = new DeviceWlan(deviceWlanProps); - - expect(deviceWlan).to.be.instanceOf(ValueObject); - expect(deviceWlan.ipv4).to.be.equal(deviceWlanProps.ipv4); - expect(deviceWlan.ssid).to.be.equal(deviceWlanProps.ssid); - expect(deviceWlan.port).to.be.equal(deviceWlanProps.port); - expect(deviceWlan.mask).to.be.equal(deviceWlanProps.mask); - expect(deviceWlan.mac).to.be.equal(deviceWlanProps.mac); - }); - - it("should throw an error when 'ipv4' property is not provided", function () { - // @ts-expect-error - missing property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), ipv4: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'ipv4' for DeviceWlan not provided`, - ); - }); - - it("should throw an error when 'ipv4' property is invalid", function () { - // @ts-expect-error - invalid property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), ipv4: 1 })).to.throw( - ArgumentInvalidException, - `Value '1' for property 'ipv4' of DeviceWlan is not a string`, - ); - }); - - it("should throw an error when 'ssid' property is not provided", function () { - // @ts-expect-error - missing property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), ssid: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'ssid' for DeviceWlan not provided`, - ); - }); - - it("should throw an error when 'ssid' property is invalid", function () { - // @ts-expect-error - invalid property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), ssid: 1 })).to.throw( - ArgumentInvalidException, - `Value '1' for property 'ssid' of DeviceWlan is not a string`, - ); - }); - - it("should throw an error when 'port' property is not provided", function () { - // @ts-expect-error - missing property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), port: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'port' for DeviceWlan not provided`, - ); - }); - - it("should throw an error when 'port' property is invalid", function () { - // @ts-expect-error - invalid property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), port: 'foo' })).to.throw( - ArgumentInvalidException, - `Value 'foo' for property 'port' of DeviceWlan is not a number`, - ); - }); - - it("should throw an error when 'mask' property is not provided", function () { - // @ts-expect-error - missing property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), mask: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'mask' for DeviceWlan not provided`, - ); - }); - - it("should throw an error when 'mask' property is invalid", function () { - // @ts-expect-error - invalid property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), mask: 1 })).to.throw( - ArgumentInvalidException, - `Value '1' for property 'mask' of DeviceWlan is not a string`, - ); - }); - - it("should throw an error when 'mac' property is not provided", function () { - // @ts-expect-error - missing property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), mac: undefined })).to.throw( - ArgumentNotProvidedException, - `Property 'mac' for DeviceWlan not provided`, - ); - }); - - it("should throw an error when 'mac' property is invalid", function () { - // @ts-expect-error - invalid property - expect(() => new DeviceWlan({ ...givenSomeDeviceWlanProps(), mac: 1 })).to.throw( - ArgumentInvalidException, - `Value '1' for property 'mac' of DeviceWlan is not a string`, - ); - }); -}); diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 593b76bc..ff02f8fa 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -10,10 +10,11 @@ "./typescript": "./typescript.js" }, "dependencies": { - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", - "eslint": "^8.35.0", - "eslint-config-prettier": "^8.6.0", + "@guardian/eslint-plugin-tsdoc-required": "^0.1.3", + "@typescript-eslint/eslint-plugin": "^5.56.0", + "@typescript-eslint/parser": "^5.56.0", + "eslint": "^8.36.0", + "eslint-config-prettier": "^8.8.0", "eslint-import-resolver-typescript": "^3.5.3", "eslint-plugin-chai-friendly": "^0.7.2", "eslint-plugin-import": "^2.27.5", diff --git a/packages/eslint-config/typescript.js b/packages/eslint-config/typescript.js index b92c2d5e..2e64e48b 100644 --- a/packages/eslint-config/typescript.js +++ b/packages/eslint-config/typescript.js @@ -18,7 +18,7 @@ module.exports = { 'plugin:chai-friendly/recommended', 'plugin:prettier/recommended', ], - plugins: ['eslint-plugin-tsdoc'], + plugins: ['eslint-plugin-tsdoc', '@guardian/eslint-plugin-tsdoc-required'], settings: { 'import/parsers': { '@typescript-eslint/parser': ['.ts'], @@ -30,11 +30,13 @@ module.exports = { }, }, rules: { + 'object-shorthand': ['error', 'always'], 'tsdoc/syntax': 'warn', 'node/no-missing-import': 'off', 'node/no-extraneous-import': 'off', 'node/no-unsupported-features/es-syntax': 'off', 'security/detect-object-injection': 'off', + '@typescript-eslint/require-await': 'off', '@typescript-eslint/explicit-module-boundary-types': 'error', '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/consistent-type-exports': 'warn', @@ -55,6 +57,16 @@ module.exports = { forbid: ['packages/**/*', '@agnoc/*/src/**/*'], }, ], + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['**/*.test.ts', '**/test/**/*.ts'], + optionalDependencies: false, + peerDependencies: true, + includeInternal: true, + }, + ], + 'import/no-unresolved': 'off', 'import/order': [ 'warn', { @@ -76,5 +88,11 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', }, }, + { + files: ['packages/toolkit/src/**/*.ts'], + rules: { + '@guardian/tsdoc-required/tsdoc-required': 'warn', + }, + }, ], }; diff --git a/packages/schemas-tcp/src/index.proto b/packages/schemas-tcp/src/index.proto index dfa8a226..a119fd35 100644 --- a/packages/schemas-tcp/src/index.proto +++ b/packages/schemas-tcp/src/index.proto @@ -295,7 +295,7 @@ message DEVICE_ORDERLIST_SETTING_REQ { required uint32 planId = 2; required uint32 cleanMode = 3; required uint32 windPower = 4; - required uint32 waterLevel = 5; + optional uint32 waterLevel = 5; required bool twiceClean = 6; } @@ -341,8 +341,8 @@ message DEVICE_EVENT_REPORT_CLEANTASK { required uint32 cleanId = 1; required uint32 startTime = 2; required uint32 endTime = 3; - required uint32 unk4 = 4; - required uint32 unk5 = 5; + required uint32 cleanSize = 4; + required uint32 cleanTime = 5; required uint32 unk6 = 6; required uint32 unk7 = 7; required Unk1 unk8 = 8; @@ -357,7 +357,7 @@ message DEVICE_CLEANMAP_BINDATA_REPORT_REQ { message DEVICE_CLEANMAP_BINDATA_REPORT_RSP { required int32 result = 1; - required uint32 cleanId = 3; + optional uint32 cleanId = 3; } message DEVICE_OFFLINE_CMD { @@ -569,3 +569,24 @@ message DEVICE_MOP_FLOOR_CLEAN_REQ { message DEVICE_MOP_FLOOR_CLEAN_RSP { required int32 result = 1; } + +message USER_DELETE_DEVICE_ORDER_REQ { + required string unk1 = 1; + required uint32 deviceId = 2; + required uint32 orderId = 3; +} + +message USER_DELETE_DEVICE_ORDER_RSP { + required int32 result = 1; + required uint32 unk1 = 3; +} + +message DEVICE_MAPID_SET_NAME_PARAMS_REQ { + required uint32 mapHeadId = 1; + required string name = 3; +} + +message DEVICE_MAPID_SET_NAME_PARAMS_RSP { + required int32 result = 1; + required uint32 unk1 = 3; +} diff --git a/packages/test-support/.gitignore b/packages/test-support/.gitignore new file mode 100644 index 00000000..4d8631aa --- /dev/null +++ b/packages/test-support/.gitignore @@ -0,0 +1,6 @@ +*.tgz +.nyc_output/ +lib/ +coverage/ +node_modules/ + diff --git a/packages/test-support/README.md b/packages/test-support/README.md new file mode 100644 index 00000000..309e6d6b --- /dev/null +++ b/packages/test-support/README.md @@ -0,0 +1,3 @@ +# @agnoc/test-support + +The test support component of the library. diff --git a/packages/test-support/package.json b/packages/test-support/package.json new file mode 100644 index 00000000..c2acae4f --- /dev/null +++ b/packages/test-support/package.json @@ -0,0 +1,79 @@ +{ + "name": "@agnoc/test-support", + "version": "0.18.0-next.0", + "description": "Agnoc test-support library", + "keywords": [ + "agnoc", + "test-support", + "conga", + "cecotec", + "driver", + "reverse", + "engineering" + ], + "homepage": "https://github.com/adrigzr/agnoc", + "bugs": { + "url": "https://github.com/adrigzr/agnoc/issues" + }, + "license": "MIT", + "author": "Adrián González Rus (https://github.com/adrigzr)", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "files": [ + "lib" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/adrigzr/agnoc.git", + "directory": "packages/test-support" + }, + "scripts": { + "build": "npm-run-all build:lib", + "build:lib": "tsc -b tsconfig.build.json", + "clean": "rm -rf .build-cache lib coverage", + "posttest": "test \"$(cat coverage/coverage-summary.json | json total.lines.total)\" -gt 0", + "test": "nyc mocha src" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "{workspaceRoot}/package.json", + "{workspaceRoot}/tsconfig.*", + "{projectRoot}/src/**/*", + "{projectRoot}/package.json", + "{projectRoot}/tsconfig.*" + ], + "outputs": [ + "{projectRoot}/lib", + "{projectRoot}/.build-cache" + ] + }, + "test": { + "inputs": [ + "{workspaceRoot}/.mocharc.yml", + "{workspaceRoot}/nyc.config.js", + "{projectRoot}/src/**/*" + ], + "outputs": [ + "{projectRoot}/coverage" + ] + } + } + }, + "dependencies": { + "tslib": "^2.5.0" + }, + "devDependencies": { + "chai": "^4.3.7" + }, + "engines": { + "node": ">=18.12" + }, + "typedoc": { + "displayName": "Test Support" + } +} diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts new file mode 100644 index 00000000..95d0e040 --- /dev/null +++ b/packages/test-support/src/index.ts @@ -0,0 +1 @@ +export * from './utils/read-stream.util'; diff --git a/packages/test-support/src/utils/read-stream.util.test.ts b/packages/test-support/src/utils/read-stream.util.test.ts new file mode 100644 index 00000000..1cfa5b18 --- /dev/null +++ b/packages/test-support/src/utils/read-stream.util.test.ts @@ -0,0 +1,47 @@ +import { Readable } from 'stream'; +import { expect } from 'chai'; +import { readStream } from './read-stream.util'; + +describe('readStream', function () { + it('should read data from stream', async function () { + const stream = new Readable(); + const reader = readStream(stream); + + stream.push('a'); + stream.push('b'); + stream.push(null); + + expect(await reader).to.deep.equal([Buffer.from('a'), Buffer.from('b')]); + }); + + it('should read data from stream with encoding', async function () { + const stream = new Readable(); + const reader = readStream(stream, 'utf8'); + + stream.push('a'); + stream.push('b'); + stream.push(null); + + expect(await reader).to.deep.equal(['a', 'b']); + }); + + it('should reject if stream emits error', async function () { + const stream = new Readable(); + const reader = readStream(stream); + + stream.emit('error', new Error('test')); + + await expect(reader).to.be.rejectedWith('test'); + }); + + it('should read object data from stream', async function () { + const stream = new Readable({ objectMode: true }); + const reader = readStream(stream); + + stream.push({ a: 1 }); + stream.push({ b: 2 }); + stream.push(null); + + expect(await reader).to.deep.equal([{ a: 1 }, { b: 2 }]); + }); +}); diff --git a/packages/test-support/src/utils/read-stream.util.ts b/packages/test-support/src/utils/read-stream.util.ts new file mode 100644 index 00000000..c10d0d82 --- /dev/null +++ b/packages/test-support/src/utils/read-stream.util.ts @@ -0,0 +1,18 @@ +import type { Readable } from 'stream'; + +/** Reads all data from a stream. */ +export function readStream(stream: Readable): Promise; +export function readStream(stream: Readable, encoding: BufferEncoding): Promise; +export function readStream(stream: Readable, encoding?: BufferEncoding): Promise<(T | string)[]> { + if (encoding) { + stream.setEncoding(encoding); + } + + return new Promise((resolve, reject) => { + const data: unknown[] = []; + + stream.on('data', (chunk) => data.push(chunk)); + stream.on('end', () => resolve(data as T[])); + stream.on('error', (error) => reject(error)); + }); +} diff --git a/packages/test-support/tsconfig.build.json b/packages/test-support/tsconfig.build.json new file mode 100644 index 00000000..978537b4 --- /dev/null +++ b/packages/test-support/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "tsBuildInfoFile": ".build-cache/tsconfig.build.tsbuildinfo" + }, + "include": ["src"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 07181889..c84529b8 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -66,10 +66,11 @@ }, "dependencies": { "debug": "^4.3.4", - "tiny-typed-emitter": "^2.1.0", + "emittery": "^0.13.1", "tslib": "^2.5.0" }, "devDependencies": { + "@johanblumenberg/ts-mockito": "^1.0.35", "chai": "^4.3.7" }, "engines": { diff --git a/packages/toolkit/src/adapters/memory.adapter.test.ts b/packages/toolkit/src/adapters/memory.adapter.test.ts new file mode 100644 index 00000000..7717c9ef --- /dev/null +++ b/packages/toolkit/src/adapters/memory.adapter.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; +import { ID } from '../domain-primitives/id.domain-primitive'; +import { MemoryAdapter } from './memory.adapter'; + +describe('MemoryAdapter', function () { + it('should get and set data', function () { + const adapter = new MemoryAdapter(); + const id = ID.generate(); + const data = { foo: 'bar' }; + + adapter.set(id, data); + + expect(adapter.get(id)).to.equal(data); + }); + + it('should delete data', function () { + const adapter = new MemoryAdapter(); + const id = ID.generate(); + const data = { foo: 'bar' }; + + adapter.set(id, data); + adapter.delete(id); + + expect(adapter.get(id)).to.be.undefined; + }); + + it('should get all data', function () { + const adapter = new MemoryAdapter(); + const id1 = ID.generate(); + const id2 = ID.generate(); + const data1 = { foo: 'bar' }; + const data2 = { foo: 'baz' }; + + adapter.set(id1, data1); + adapter.set(id2, data2); + + expect(adapter.getAll()).to.deep.equal([data1, data2]); + }); +}); diff --git a/packages/toolkit/src/adapters/memory.adapter.ts b/packages/toolkit/src/adapters/memory.adapter.ts new file mode 100644 index 00000000..cfac612f --- /dev/null +++ b/packages/toolkit/src/adapters/memory.adapter.ts @@ -0,0 +1,23 @@ +import { Adapter } from '../base-classes/adapter.base'; +import type { ID } from '../domain-primitives/id.domain-primitive'; + +/** Adapter for storing data in memory. */ +export class MemoryAdapter extends Adapter { + private readonly data = new Map(); + + get(id: ID): unknown { + return this.data.get(id.value); + } + + set(id: ID, value: unknown): void { + this.data.set(id.value, value); + } + + delete(id: ID): void { + this.data.delete(id.value); + } + + getAll(): unknown[] { + return Array.from(this.data.values()); + } +} diff --git a/packages/toolkit/src/base-classes/adapter.base.ts b/packages/toolkit/src/base-classes/adapter.base.ts new file mode 100644 index 00000000..f02dd9bc --- /dev/null +++ b/packages/toolkit/src/base-classes/adapter.base.ts @@ -0,0 +1,9 @@ +import type { ID } from '../domain-primitives/id.domain-primitive'; + +/** Base class for all adapters. */ +export abstract class Adapter { + abstract getAll(): unknown[]; + abstract get(id: ID): unknown; + abstract set(id: ID, value: unknown): void; + abstract delete(id: ID): void; +} diff --git a/packages/toolkit/src/base-classes/aggregate-root.base.test.ts b/packages/toolkit/src/base-classes/aggregate-root.base.test.ts new file mode 100644 index 00000000..97c45c89 --- /dev/null +++ b/packages/toolkit/src/base-classes/aggregate-root.base.test.ts @@ -0,0 +1,97 @@ +import { anything, capture, defer, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ID } from '../domain-primitives/id.domain-primitive'; +import { AggregateRoot } from './aggregate-root.base'; +import { DomainEvent } from './domain-event.base'; +import { Entity } from './entity.base'; +import type { DomainEventProps } from './domain-event.base'; +import type { EntityProps } from './entity.base'; +import type { EventBus } from './event-bus.base'; + +describe('AggregateRoot', function () { + let eventBus: EventBus; + + beforeEach(function () { + eventBus = imock(); + }); + + it('should be created', function () { + const dummyAggregateRoot = new DummyAggregateRoot({ id: ID.generate() }); + + expect(dummyAggregateRoot).to.be.instanceOf(Entity); + }); + + it('should be able to add domain events', function () { + const dummyAggregateRoot = new DummyAggregateRoot({ id: ID.generate() }); + + dummyAggregateRoot.doSomething(); + + expect(dummyAggregateRoot.domainEvents).to.be.lengthOf(1); + expect(dummyAggregateRoot.domainEvents[0]).to.be.instanceOf(DummyDomainEvent); + }); + + it('should be able to publish domain events', async function () { + const id = ID.generate(); + const dummyAggregateRoot = new DummyAggregateRoot({ id }); + + when(eventBus.emit(anything(), anything())).thenResolve(); + + dummyAggregateRoot.doSomething(); + + await dummyAggregateRoot.publishEvents(instance(eventBus)); + + expect(dummyAggregateRoot.domainEvents).to.be.lengthOf(0); + + const [, event] = capture(eventBus.emit).first(); + + expect(event).to.be.instanceOf(DummyDomainEvent); + expect(event.aggregateId).to.be.equal(id); + }); + + it('should publish domain events only once', async function () { + const id = ID.generate(); + const dummyAggregateRoot = new DummyAggregateRoot({ id }); + const deferEmit = defer(); + + when(eventBus.emit(anything(), anything())).thenReturn(deferEmit); + + dummyAggregateRoot.doSomething(); + + const promises = Promise.all([ + dummyAggregateRoot.publishEvents(instance(eventBus)), + dummyAggregateRoot.publishEvents(instance(eventBus)), + ]); + + await deferEmit.resolve(undefined); + await promises; + + expect(dummyAggregateRoot.domainEvents).to.be.lengthOf(0); + + verify(eventBus.emit(anything(), anything())).once(); + }); + + it('should be able to clear domain events', function () { + const dummyAggregateRoot = new DummyAggregateRoot({ id: ID.generate() }); + + dummyAggregateRoot.doSomething(); + dummyAggregateRoot.clearEvents(); + + expect(dummyAggregateRoot.domainEvents).to.be.lengthOf(0); + }); +}); + +class DummyDomainEvent extends DomainEvent { + protected validate(_: DomainEventProps): void { + // noop + } +} + +class DummyAggregateRoot extends AggregateRoot { + doSomething(): void { + this.addEvent(new DummyDomainEvent({ aggregateId: this.id })); + } + + protected validate(_: EntityProps): void { + // noop + } +} diff --git a/packages/toolkit/src/base-classes/aggregate-root.base.ts b/packages/toolkit/src/base-classes/aggregate-root.base.ts new file mode 100644 index 00000000..35b5a9e3 --- /dev/null +++ b/packages/toolkit/src/base-classes/aggregate-root.base.ts @@ -0,0 +1,42 @@ +import { debug } from '../utils/debug.util'; +import { toDashCase } from '../utils/to-dash-case.util'; +import { Entity } from './entity.base'; +import type { DomainEvent } from './domain-event.base'; +import type { EntityProps } from './entity.base'; +import type { EventBus } from './event-bus.base'; + +/** Base class for aggregate roots. */ +export abstract class AggregateRoot extends Entity { + private readonly debug = debug(__filename).extend(toDashCase(this.constructor.name)).extend(this.id.toString()); + readonly #domainEvents = new Set(); + + /** Returns the domain events that have been added to the aggregate root. */ + get domainEvents(): DomainEvent[] { + return [...this.#domainEvents.values()]; + } + + /** Clears the domain events that have been added to the aggregate root. */ + clearEvents(): void { + this.#domainEvents.clear(); + } + + /** Publishes the domain events that have been added to the aggregate root and clears them. */ + async publishEvents(eventBus: EventBus): Promise { + const domainEvents = this.domainEvents; + + this.clearEvents(); + + await Promise.all( + domainEvents.map(async (domainEvent) => { + this.debug( + `publishing domain event '${domainEvent.constructor.name}' with data: ${JSON.stringify(domainEvent)}`, + ); + return eventBus.emit(domainEvent.constructor.name, domainEvent); + }), + ); + } + + protected addEvent(domainEvent: DomainEvent): void { + this.#domainEvents.add(domainEvent); + } +} diff --git a/packages/toolkit/src/base-classes/command.base.test.ts b/packages/toolkit/src/base-classes/command.base.test.ts new file mode 100644 index 00000000..cf6c6230 --- /dev/null +++ b/packages/toolkit/src/base-classes/command.base.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { Command } from './command.base'; +import { Task } from './task.base'; + +describe('Command', function () { + it('should be created', function () { + const dummyCommand = new DummyCommand({ foo: 'bar' }); + + expect(dummyCommand).to.be.instanceOf(Task); + }); +}); + +interface DummyCommandInput { + foo: string; +} + +interface DummyCommandOutput { + bar: string; +} + +class DummyCommand extends Command { + protected validate(): void { + // noop + } +} diff --git a/packages/toolkit/src/base-classes/command.base.ts b/packages/toolkit/src/base-classes/command.base.ts new file mode 100644 index 00000000..0e0f1f2d --- /dev/null +++ b/packages/toolkit/src/base-classes/command.base.ts @@ -0,0 +1,4 @@ +import { Task } from './task.base'; + +/** Base class for commands. */ +export abstract class Command extends Task {} diff --git a/packages/toolkit/src/base-classes/domain-event.base.test.ts b/packages/toolkit/src/base-classes/domain-event.base.test.ts new file mode 100644 index 00000000..4a1f0250 --- /dev/null +++ b/packages/toolkit/src/base-classes/domain-event.base.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'chai'; +import { ID } from '../domain-primitives/id.domain-primitive'; +import { DomainEvent } from './domain-event.base'; +import { Validatable } from './validatable.base'; +import type { DomainEventProps } from './domain-event.base'; + +describe('DomainEvent', function () { + it('should be created', function () { + const now = Date.now(); + const aggregateId = ID.generate(); + const dummyDomainEvent = new DummyDomainEvent({ aggregateId }); + + expect(dummyDomainEvent).to.be.instanceOf(Validatable); + expect(dummyDomainEvent.id).to.be.instanceOf(ID); + expect(dummyDomainEvent.aggregateId).to.be.instanceOf(ID); + expect(dummyDomainEvent.metadata.timestamp).to.be.greaterThanOrEqual(now); + }); +}); + +class DummyDomainEvent extends DomainEvent { + protected validate(_: DomainEventProps): void { + // noop + } +} diff --git a/packages/toolkit/src/base-classes/domain-event.base.ts b/packages/toolkit/src/base-classes/domain-event.base.ts new file mode 100644 index 00000000..580138e9 --- /dev/null +++ b/packages/toolkit/src/base-classes/domain-event.base.ts @@ -0,0 +1,32 @@ +import { ID } from '../domain-primitives/id.domain-primitive'; +import { Validatable } from './validatable.base'; + +/** Properties for domain events. */ +export interface DomainEventProps { + /** ID of the aggregate the event belongs to. */ + aggregateId: ID; +} + +/** Metadata for domain events. */ +export interface DomainEventMetadata { + /** Timestamp of the event. */ + timestamp: number; +} + +/** Base class for domain events. */ +export abstract class DomainEvent extends Validatable { + readonly id = ID.generate(); + readonly eventName = this.constructor.name; + readonly metadata: DomainEventMetadata; + + constructor(protected readonly props: T) { + super(props); + this.metadata = { + timestamp: Date.now(), + }; + } + + get aggregateId(): ID { + return this.props.aggregateId; + } +} diff --git a/packages/toolkit/src/base-classes/domain-primitive.base.ts b/packages/toolkit/src/base-classes/domain-primitive.base.ts index 6dbae8eb..ccdec1f6 100644 --- a/packages/toolkit/src/base-classes/domain-primitive.base.ts +++ b/packages/toolkit/src/base-classes/domain-primitive.base.ts @@ -1,13 +1,17 @@ import { ArgumentInvalidException } from '../exceptions/argument-invalid.exception'; import { ValueObject } from './value-object.base'; -export type Primitives = string | number | boolean | bigint | Date; -export interface DomainPrimitiveProps { +/** Possible domain primitive types. */ +export type DomainPrimitives = string | number | boolean | bigint | Date; + +/** Props for the DomainPrimitive class. */ +export interface DomainPrimitiveProps { + /** The value of the primitive. */ value: T; } /** Abstract base class that provides basic tools for building the domain primitives of the domain. */ -export abstract class DomainPrimitive extends ValueObject> { +export abstract class DomainPrimitive extends ValueObject> { /** Checks if the provided value is a primitive value. */ constructor(value: T) { checkIfPrimitiveValue(new.target.name, value); @@ -38,7 +42,7 @@ export abstract class DomainPrimitive extends ValueObject< protected abstract override validate(props: DomainPrimitiveProps): void; } -function isPrimitiveValue(value: unknown): value is Primitives { +function isPrimitiveValue(value: unknown): value is DomainPrimitives { return ( typeof value === 'string' || typeof value === 'number' || diff --git a/packages/toolkit/src/base-classes/entity.base.ts b/packages/toolkit/src/base-classes/entity.base.ts index 1a325cfe..6aeb6dbd 100644 --- a/packages/toolkit/src/base-classes/entity.base.ts +++ b/packages/toolkit/src/base-classes/entity.base.ts @@ -3,11 +3,14 @@ import { convertPropsToObject } from '../utils/convert-props-to-object.util'; import { isPresent } from '../utils/is-present.util'; import { Validatable } from './validatable.base'; +/** Props for all entities. */ export interface EntityProps { + /** ID of the entity. */ id: ID; } -export abstract class Entity extends Validatable { +/** Base class for all entities. */ +export abstract class Entity extends Validatable { constructor(props: T) { super(props); this.validateId(props); diff --git a/packages/toolkit/src/base-classes/event-bus.base.test.ts b/packages/toolkit/src/base-classes/event-bus.base.test.ts new file mode 100644 index 00000000..4f3ddbd1 --- /dev/null +++ b/packages/toolkit/src/base-classes/event-bus.base.test.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import { EventBus } from './event-bus.base'; + +describe('EventBus', function () { + const eventName = 'event'; + let dummyEventBus: DummyEventBus; + + beforeEach(function () { + dummyEventBus = new DummyEventBus(); + }); + + it('should listen for events on the bus', function () { + dummyEventBus.on(eventName, (data) => { + expect(data).to.deep.equal({ foo: 'bar' }); + }); + + return dummyEventBus.emit(eventName, { foo: 'bar' }); + }); + + it('should throw an error when a listener for an event throws an error', function () { + dummyEventBus.on(eventName, () => { + // noop + }); + + dummyEventBus.on(eventName, () => { + throw new Error('event error'); + }); + + return expect(dummyEventBus.emit(eventName, { foo: 'bar' })).to.be.rejectedWith(Error, 'event error'); + }); + + it('should reject with an error when a listener for an event rejects with an error', function () { + const eventName = 'event'; + + dummyEventBus.on(eventName, () => { + // noop + }); + + dummyEventBus.on(eventName, () => { + return Promise.reject(new Error('event error')); + }); + + return expect(dummyEventBus.emit(eventName, { foo: 'bar' })).to.be.rejectedWith(Error, 'event error'); + }); +}); + +class DummyEventBus extends EventBus {} diff --git a/packages/toolkit/src/base-classes/event-bus.base.ts b/packages/toolkit/src/base-classes/event-bus.base.ts new file mode 100644 index 00000000..5f81e91a --- /dev/null +++ b/packages/toolkit/src/base-classes/event-bus.base.ts @@ -0,0 +1,5 @@ +import Emittery from 'emittery'; + +/** Base class for event bus. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class EventBus extends Emittery {} diff --git a/packages/toolkit/src/base-classes/event-handler.base.ts b/packages/toolkit/src/base-classes/event-handler.base.ts new file mode 100644 index 00000000..e214a809 --- /dev/null +++ b/packages/toolkit/src/base-classes/event-handler.base.ts @@ -0,0 +1,6 @@ +/** Base class for event handlers. */ +export abstract class EventHandler { + // eslint-disable-next-line @typescript-eslint/ban-types + abstract forName: string; + abstract handle(...args: unknown[]): void; +} diff --git a/packages/toolkit/src/base-classes/exception.base.ts b/packages/toolkit/src/base-classes/exception.base.ts index d0a7495a..f1f6831c 100644 --- a/packages/toolkit/src/base-classes/exception.base.ts +++ b/packages/toolkit/src/base-classes/exception.base.ts @@ -1,10 +1,16 @@ import type { ObjectLiteral } from '../types/object-literal.type'; +/** JSON representation of an exception. */ export interface JSONException { + /** Name of the exception. */ name: string; + /** Message of the exception. */ message: string; + /** Stack trace of the exception. */ stack?: string; + /** Metadata of the exception. */ metadata?: ObjectLiteral; + /** Cause of the exception. */ cause?: JSONException | unknown; } diff --git a/packages/toolkit/src/base-classes/factory.base.ts b/packages/toolkit/src/base-classes/factory.base.ts index ba605813..3525a06b 100644 --- a/packages/toolkit/src/base-classes/factory.base.ts +++ b/packages/toolkit/src/base-classes/factory.base.ts @@ -1,3 +1,4 @@ +/** Base class for factories. */ export abstract class Factory { abstract create(...args: unknown[]): T; } diff --git a/packages/toolkit/src/base-classes/query.base.test.ts b/packages/toolkit/src/base-classes/query.base.test.ts new file mode 100644 index 00000000..63c4314a --- /dev/null +++ b/packages/toolkit/src/base-classes/query.base.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { Query } from './query.base'; +import { Task } from './task.base'; + +describe('Query', function () { + it('should be created', function () { + const dummyQuery = new DummyQuery({ foo: 'bar' }); + + expect(dummyQuery).to.be.instanceOf(Task); + }); +}); + +interface DummyQueryInput { + foo: string; +} + +interface DummyQueryOutput { + bar: string; +} + +class DummyQuery extends Query { + protected validate(): void { + // noop + } +} diff --git a/packages/toolkit/src/base-classes/query.base.ts b/packages/toolkit/src/base-classes/query.base.ts new file mode 100644 index 00000000..9b1fcd2f --- /dev/null +++ b/packages/toolkit/src/base-classes/query.base.ts @@ -0,0 +1,4 @@ +import { Task } from './task.base'; + +/** Base class for all queries. */ +export abstract class Query extends Task {} diff --git a/packages/toolkit/src/base-classes/repository.base.test.ts b/packages/toolkit/src/base-classes/repository.base.test.ts new file mode 100644 index 00000000..174712be --- /dev/null +++ b/packages/toolkit/src/base-classes/repository.base.test.ts @@ -0,0 +1,76 @@ +import { anything, imock, instance, spy, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { ID } from '../domain-primitives/id.domain-primitive'; +import { AggregateRoot } from './aggregate-root.base'; +import { Repository } from './repository.base'; +import type { Adapter } from './adapter.base'; +import type { EntityProps } from './entity.base'; +import type { EventBus } from './event-bus.base'; + +describe('Repository', function () { + let eventBus: EventBus; + let adapter: Adapter; + let dummyRepository: DummyRepository; + + beforeEach(function () { + eventBus = imock(); + adapter = imock(); + dummyRepository = new DummyRepository(instance(eventBus), instance(adapter)); + }); + + it('should find one by id', async function () { + const id = ID.generate(); + const entity = new DummyAggregateRoot({ id }); + + when(adapter.get(anything())).thenReturn(entity); + + const ret = await dummyRepository.findOneById(id); + + expect(ret).to.be.equal(entity); + + verify(adapter.get(id)).once(); + }); + + it('should find all', async function () { + const id = ID.generate(); + const entity = new DummyAggregateRoot({ id }); + + when(adapter.getAll()).thenReturn([entity]); + + const ret = await dummyRepository.findAll(); + + expect(ret).to.be.deep.equal([entity]); + + verify(adapter.getAll()).once(); + }); + + it('should save one', async function () { + const id = ID.generate(); + const entity = new DummyAggregateRoot({ id }); + const entitySpy = spy(entity); + + await dummyRepository.saveOne(entity); + + verify(adapter.set(id, entity)).once(); + verify(entitySpy.publishEvents(instance(eventBus))).once(); + }); + + it('should delete one', async function () { + const id = ID.generate(); + const entity = new DummyAggregateRoot({ id }); + const entitySpy = spy(entity); + + await dummyRepository.deleteOne(entity); + + verify(adapter.delete(id)).once(); + verify(entitySpy.publishEvents(instance(eventBus))).once(); + }); +}); + +class DummyAggregateRoot extends AggregateRoot { + protected validate(_: EntityProps): void { + // noop + } +} + +class DummyRepository extends Repository {} diff --git a/packages/toolkit/src/base-classes/repository.base.ts b/packages/toolkit/src/base-classes/repository.base.ts new file mode 100644 index 00000000..7ae6e85a --- /dev/null +++ b/packages/toolkit/src/base-classes/repository.base.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/require-await */ +import type { Adapter } from './adapter.base'; +import type { AggregateRoot } from './aggregate-root.base'; +import type { EntityProps } from './entity.base'; +import type { EventBus } from './event-bus.base'; +import type { ID } from '../domain-primitives/id.domain-primitive'; + +/** Base class for repositories. */ +export abstract class Repository> { + constructor(private readonly eventBus: EventBus, protected readonly adapter: Adapter) {} + + async findOneById(id: ID): Promise { + return this.adapter.get(id) as T | undefined; + } + + async findAll(): Promise { + return this.adapter.getAll() as T[]; + } + + async saveOne(entity: T): Promise { + this.adapter.set(entity.id, entity); + await entity.publishEvents(this.eventBus); + } + + async deleteOne(entity: T): Promise { + this.adapter.delete(entity.id); + await entity.publishEvents(this.eventBus); + } +} diff --git a/packages/toolkit/src/base-classes/server.base.ts b/packages/toolkit/src/base-classes/server.base.ts new file mode 100644 index 00000000..e023b06c --- /dev/null +++ b/packages/toolkit/src/base-classes/server.base.ts @@ -0,0 +1,5 @@ +/** Base class for server implementations. */ +export abstract class Server { + abstract listen(options?: unknown): Promise; + abstract close(): Promise; +} diff --git a/packages/toolkit/src/base-classes/task-bus.base.test.ts b/packages/toolkit/src/base-classes/task-bus.base.test.ts new file mode 100644 index 00000000..4a4ceb27 --- /dev/null +++ b/packages/toolkit/src/base-classes/task-bus.base.test.ts @@ -0,0 +1,104 @@ +import { expect } from 'chai'; +import { DomainException } from '../exceptions/domain.exception'; +import { TaskBus } from './task-bus.base'; +import { Task } from './task.base'; + +describe('TaskBus', function () { + let dummyTaskBus: DummyTaskBus; + + beforeEach(function () { + dummyTaskBus = new DummyTaskBus(); + }); + + it('should subscribe and trigger tasks', async function () { + dummyTaskBus.subscribe('IOTask', (task) => { + expect(task).to.be.instanceOf(IOTask); + + return { bar: 'bar' }; + }); + dummyTaskBus.subscribe('ITask', (task) => { + expect(task).to.be.instanceOf(ITask); + }); + + const ioTaskOutput = await dummyTaskBus.trigger(new IOTask({ foo: 'foo' })); + + expect(ioTaskOutput).to.be.deep.equal({ bar: 'bar' }); + + const iTaskOutput = await dummyTaskBus.trigger(new ITask({ wow: 'wow' })); + + expect(iTaskOutput).to.be.undefined; + }); + + it('should subscribe async tasks', async function () { + dummyTaskBus.subscribe('IOTask', async () => { + return { bar: 'bar' }; + }); + + const ioTaskOutput = await dummyTaskBus.trigger(new IOTask({ foo: 'foo' })); + + expect(ioTaskOutput).to.be.deep.equal({ bar: 'bar' }); + }); + + it('should throw an error when no handlers are registered', async function () { + await expect(dummyTaskBus.trigger(new IOTask({ foo: 'foo' }))).to.be.rejectedWith( + DomainException, + 'No handlers registered for IOTask', + ); + }); + + it('should throw an error when no handlers are fulfilled', async function () { + dummyTaskBus.subscribe('IOTask', () => { + throw new Error('foo'); + }); + + await expect(dummyTaskBus.trigger(new IOTask({ foo: 'foo' }))).to.be.rejectedWith( + DomainException, + 'No handlers fulfilled for IOTask', + ); + }); + + it('should throw an error when more than one handler is fulfilled', async function () { + dummyTaskBus.subscribe('IOTask', () => { + return { bar: 'bar' }; + }); + dummyTaskBus.subscribe('IOTask', () => { + return { bar: 'bar' }; + }); + + await expect(dummyTaskBus.trigger(new IOTask({ foo: 'foo' }))).to.be.rejectedWith( + DomainException, + 'Multiple handlers fulfilled for IOTask', + ); + }); +}); + +interface IOTaskInput { + foo: string; +} + +interface IOTaskOutput { + bar: string; +} + +class IOTask extends Task { + protected validate() { + // noop + } +} + +interface ITaskInput { + wow: string; +} + +class ITask extends Task { + protected validate() { + // noop + } +} + +type DummyTaskBusTasks = { + IOTask: IOTask; + ITask: ITask; +}; + +class DummyTaskBus extends TaskBus {} diff --git a/packages/toolkit/src/base-classes/task-bus.base.ts b/packages/toolkit/src/base-classes/task-bus.base.ts new file mode 100644 index 00000000..b9b5eaae --- /dev/null +++ b/packages/toolkit/src/base-classes/task-bus.base.ts @@ -0,0 +1,70 @@ +import { DomainException } from '../exceptions/domain.exception'; +import { debug } from '../utils/debug.util'; +import { toDashCase } from '../utils/to-dash-case.util'; +import type { Task, TaskOutput } from './task.base'; + +/** Task handler signature. */ +export type TaskBusSubscribeHandler = ( + task: Events[Name], +) => TaskOutput | Promise>; + +/** Task bus tasks signature. */ +export type TaskBusTasks = { + [key: string]: Task; +}; + +/** Base class for task buses. */ +export abstract class TaskBus { + private readonly debug = debug(__filename).extend(toDashCase(this.constructor.name)); + private readonly handlers = new Map>>(); + + /** Subscribes a task handler to a task. */ + subscribe(name: Name, handler: TaskBusSubscribeHandler): void { + this.debug(`subscribing to task '${name.toString()}'`); + + if (!this.handlers.has(name)) { + this.handlers.set(name, new Set()); + } + + this.handlers.get(name)?.add(handler as TaskBusSubscribeHandler); + } + + /** Triggers a task handler and returns the output. */ + async trigger(task: Task): Promise> { + const name = task.constructor.name as keyof Tasks & string; + + this.debug(`triggering task '${name}' with input: ${JSON.stringify(task)}`); + + const handlers = this.handlers.get(name as keyof Tasks); + + if (!handlers) { + throw new DomainException(`No handlers registered for ${name}`); + } + + const promises = [...handlers].map(async (handler) => handler(task)); + const outputs = await Promise.allSettled(promises); + const fulfilledOutputs = this.filterFulfilledPromises(outputs); + + if (fulfilledOutputs.length === 0) { + throw new DomainException(`No handlers fulfilled for ${name}`); + } + + if (fulfilledOutputs.length > 1) { + throw new DomainException(`Multiple handlers fulfilled for ${name}`); + } + + const output = fulfilledOutputs[0].value; + + task.validateOutput?.(output); + + this.debug(`task '${name}' completed successfully with output: ${JSON.stringify(output)}`); + + return output; + } + + private filterFulfilledPromises( + outputs: PromiseSettledResult>[], + ) { + return outputs.filter((output) => output.status === 'fulfilled') as PromiseFulfilledResult>[]; + } +} diff --git a/packages/toolkit/src/base-classes/task-handler.base.ts b/packages/toolkit/src/base-classes/task-handler.base.ts new file mode 100644 index 00000000..24bd6071 --- /dev/null +++ b/packages/toolkit/src/base-classes/task-handler.base.ts @@ -0,0 +1,6 @@ +/** Base class for event handlers. */ +export abstract class TaskHandler { + // eslint-disable-next-line @typescript-eslint/ban-types + abstract forName: string; + abstract handle(...args: unknown[]): void; +} diff --git a/packages/toolkit/src/base-classes/task.base.test.ts b/packages/toolkit/src/base-classes/task.base.test.ts new file mode 100644 index 00000000..a4769899 --- /dev/null +++ b/packages/toolkit/src/base-classes/task.base.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import { ID } from '../domain-primitives/id.domain-primitive'; +import { Task } from './task.base'; +import { Validatable } from './validatable.base'; + +describe('Task', function () { + it('should be created', function () { + const now = Date.now(); + const dummyTask = new DummyTask({ foo: 'bar' }); + + expect(dummyTask).to.be.instanceOf(Validatable); + expect(dummyTask.id).to.be.instanceOf(ID); + expect(dummyTask.taskName).to.be.equal('DummyTask'); + expect(dummyTask.metadata.timestamp).to.be.greaterThanOrEqual(now); + }); +}); + +interface DummyTaskInput { + foo: string; +} + +interface DummyTaskOutput { + bar: string; +} + +class DummyTask extends Task { + protected validate(): void { + // noop + } +} diff --git a/packages/toolkit/src/base-classes/task.base.ts b/packages/toolkit/src/base-classes/task.base.ts new file mode 100644 index 00000000..ba19b621 --- /dev/null +++ b/packages/toolkit/src/base-classes/task.base.ts @@ -0,0 +1,30 @@ +import { ID } from '../domain-primitives/id.domain-primitive'; +import { Validatable } from './validatable.base'; + +/** Input props for tasks. */ +export type TaskInput = T extends Task ? Input : never; + +/** Output props for tasks. */ +export type TaskOutput = T extends Task ? Output : never; + +/** Metadata for tasks. */ +export interface TaskMetadata { + /** Timestamp of the task. */ + timestamp: number; +} + +/** Base class for tasks. */ +export abstract class Task extends Validatable implements Task { + readonly id = ID.generate(); + readonly taskName = this.constructor.name; + readonly metadata: TaskMetadata; + + constructor(protected readonly props: Input) { + super(props); + this.metadata = { + timestamp: Date.now(), + }; + } + + validateOutput?(output: Output): void; +} diff --git a/packages/toolkit/src/base-classes/validatable.base.test.ts b/packages/toolkit/src/base-classes/validatable.base.test.ts index be994f1c..32885f24 100644 --- a/packages/toolkit/src/base-classes/validatable.base.test.ts +++ b/packages/toolkit/src/base-classes/validatable.base.test.ts @@ -5,28 +5,27 @@ import { ArgumentOutOfRangeException } from '../exceptions/argument-out-of-range import { Validatable } from './validatable.base'; describe('Validatable', function () { - it('should check for empty props', function () { + it('should check for empty props', function (done) { type ValidatableProps = { foo: string }; class DummyValidatable extends Validatable { - protected validate(_: ValidatableProps): void { - // noop + protected validate(props: ValidatableProps): void { + expect(props).to.be.undefined; + done(); } } // @ts-expect-error - missing properties - expect(() => new DummyValidatable()).to.throw( - ArgumentNotProvidedException, - 'Cannot create DummyValidatable from empty properties', - ); + new DummyValidatable(); }); - it('should invoke validate method', function () { + it('should invoke validate method', function (done) { type ValidatableProps = { foo: string }; class DummyValidatable extends Validatable { protected validate(props: ValidatableProps): void { expect(props).to.deep.equal({ foo: 'bar' }); + done(); } } diff --git a/packages/toolkit/src/base-classes/validatable.base.ts b/packages/toolkit/src/base-classes/validatable.base.ts index 9cbf018e..72f14823 100644 --- a/packages/toolkit/src/base-classes/validatable.base.ts +++ b/packages/toolkit/src/base-classes/validatable.base.ts @@ -1,7 +1,6 @@ import { ArgumentInvalidException } from '../exceptions/argument-invalid.exception'; import { ArgumentNotProvidedException } from '../exceptions/argument-not-provided.exception'; import { ArgumentOutOfRangeException } from '../exceptions/argument-out-of-range.exception'; -import { isEmpty } from '../utils/is-empty.util'; import { isPresent } from '../utils/is-present.util'; import type { Constructor } from '../types/constructor.type'; @@ -9,7 +8,6 @@ import type { Constructor } from '../types/constructor.type'; export abstract class Validatable { /** Checks if the provided props are empty and invokes the `validate` method. */ constructor(props: T) { - this.checkIfEmpty(props); this.validate(props); } @@ -17,8 +15,8 @@ export abstract class Validatable { protected abstract validate(props: T): void; /** Checks whether a prop is included in a list. */ - protected validateListProp(props: T, propName: K, list: T[K][]): void { - const value = props[propName]; + protected validateListProp(props: T | undefined, propName: K, list: T[K][]): void { + const value = props?.[propName]; if (isPresent(value) && !list.includes(value)) { throw new ArgumentInvalidException( @@ -28,8 +26,8 @@ export abstract class Validatable { } /** Checks whether a prop is a positive integer. */ - protected validatePositiveIntegerProp(props: T, propName: K): void { - const value = props[propName]; + protected validatePositiveIntegerProp(props: T | undefined, propName: K): void { + const value = props?.[propName]; if (isPresent(value) && (typeof value !== 'number' || !Number.isInteger(value) || value < 0)) { throw new ArgumentInvalidException( @@ -39,8 +37,12 @@ export abstract class Validatable { } /** Checks whether a prop is a number. Optionally check if the number is contained in a range. */ - protected validateNumberProp(props: T, propName: K, range?: NumberRange): void { - const value = props[propName]; + protected validateNumberProp( + props: T | undefined, + propName: K, + range?: ValidateNumberRange, + ): void { + const value = props?.[propName]; if (!isPresent(value)) { return; @@ -64,15 +66,19 @@ export abstract class Validatable { } /** Checks whether a prop is defined. */ - protected validateDefinedProp(props: T, propName: K): void { - if (!isPresent(props[propName])) { + protected validateDefinedProp(props: T | undefined, propName: K): void { + if (!isPresent(props?.[propName])) { throw new ArgumentNotProvidedException(`Property '${propName}' for ${this.constructor.name} not provided`); } } /** Checks whether a prop is an instance of a class. */ - protected validateInstanceProp(props: T, propName: K, ctor: Constructor): void { - const value = props[propName]; + protected validateInstanceProp( + props: T | undefined, + propName: K, + ctor: Constructor, + ): void { + const value = props?.[propName]; if (isPresent(value) && !(value instanceof ctor)) { throw new ArgumentInvalidException( @@ -84,8 +90,8 @@ export abstract class Validatable { } /** Checks whether a prop is of a certain type. */ - protected validateTypeProp(props: T, propName: K, type: string): void { - const value = props[propName]; + protected validateTypeProp(props: T | undefined, propName: K, type: string): void { + const value = props?.[propName]; if (isPresent(value) && typeof value !== type) { throw new ArgumentInvalidException( @@ -95,8 +101,12 @@ export abstract class Validatable { } /** Checks whether a prop is an array of instances of a class. */ - protected validateArrayProp(props: T, propName: K, ctor: Constructor): void { - const value = props[propName]; + protected validateArrayProp( + props: T | undefined, + propName: K, + ctor: Constructor, + ): void { + const value = props?.[propName]; if (!isPresent(value)) { return; @@ -116,15 +126,12 @@ export abstract class Validatable { ); } } - - private checkIfEmpty(props: T): void { - if (isEmpty(props)) { - throw new ArgumentNotProvidedException(`Cannot create ${this.constructor.name} from empty properties`); - } - } } -export interface NumberRange { +/** Defines a range of numbers. */ +export interface ValidateNumberRange { + /** The minimum value of the range. */ min?: number; + /** The maximum value of the range. */ max?: number; } diff --git a/packages/toolkit/src/decorators/bind.decorator.test.ts b/packages/toolkit/src/decorators/bind.decorator.test.ts deleted file mode 100644 index f7f09e96..00000000 --- a/packages/toolkit/src/decorators/bind.decorator.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect } from 'chai'; -import { bind } from './bind.decorator'; - -describe('bind.decorator', function () { - it('binds a class method', function () { - class Foo { - wow = 1; - - @bind - bar() { - return this.wow; - } - } - - const foo = new Foo(); - // eslint-disable-next-line @typescript-eslint/unbound-method - const bar = foo.bar; - - expect(bar()).to.be.equal(foo.wow); - }); - - it('throws an error when applied to a class', function () { - expect(() => { - // @ts-expect-error bad signature - @bind - class Foo {} - - new Foo(); - }).to.throw(TypeError); - }); -}); diff --git a/packages/toolkit/src/decorators/bind.decorator.ts b/packages/toolkit/src/decorators/bind.decorator.ts deleted file mode 100644 index 94f557f5..00000000 --- a/packages/toolkit/src/decorators/bind.decorator.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-types */ -export function bind( - _: unknown, - propertyKey: string, - descriptor: TypedPropertyDescriptor, -): TypedPropertyDescriptor { - if (!descriptor || typeof descriptor.value !== 'function') { - throw new TypeError(`Only methods can be decorated with @bind. <${propertyKey}> is not a method!`); - } - - return { - configurable: true, - get(this: T): T { - const bound = (descriptor.value as Function).bind(this) as T; - - Object.defineProperty(this, propertyKey, { - value: bound, - configurable: true, - writable: true, - }); - - return bound; - }, - }; -} diff --git a/packages/toolkit/src/event-handler.registry.test.ts b/packages/toolkit/src/event-handler.registry.test.ts new file mode 100644 index 00000000..2b2489ac --- /dev/null +++ b/packages/toolkit/src/event-handler.registry.test.ts @@ -0,0 +1,40 @@ +import { anything, capture, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { EventHandlerRegistry } from './event-handler.registry'; +import type { EventBus, EventHandler } from '@agnoc/toolkit'; + +describe('EventHandlerRegistry', function () { + let eventBus: EventBus; + let eventHandler: EventHandler; + let eventHandlerManager: EventHandlerRegistry; + + beforeEach(function () { + eventBus = imock(); + eventHandler = imock(); + eventHandlerManager = new EventHandlerRegistry(instance(eventBus)); + }); + + it('should listen for events on the bus', function () { + when(eventHandler.forName).thenReturn('event'); + + eventHandlerManager.register(instance(eventHandler)); + + verify(eventBus.on('event', anything())).once(); + }); + + it('should call handle when event is emitted', async function () { + const data = { foo: 'bar' }; + + when(eventHandler.forName).thenReturn('event'); + + eventHandlerManager.register(instance(eventHandler)); + + const [forName, callback] = capture(eventBus.on<'event'>).first(); + + expect(forName).to.equal('event'); + + await callback(data); + + verify(eventHandler.handle(data)).once(); + }); +}); diff --git a/packages/toolkit/src/event-handler.registry.ts b/packages/toolkit/src/event-handler.registry.ts new file mode 100644 index 00000000..a1b3003b --- /dev/null +++ b/packages/toolkit/src/event-handler.registry.ts @@ -0,0 +1,15 @@ +import type { EventBus } from './base-classes/event-bus.base'; +import type { EventHandler } from './base-classes/event-handler.base'; + +/** Manages event handlers. */ +export class EventHandlerRegistry { + constructor(private readonly eventBus: EventBus) {} + + register(...eventHandlers: EventHandler[]): void { + eventHandlers.forEach((eventHandler) => this.addEventHandler(eventHandler)); + } + + private addEventHandler(eventHandler: EventHandler): void { + this.eventBus.on(eventHandler.forName, eventHandler.handle.bind(eventHandler)); + } +} diff --git a/packages/toolkit/src/exceptions/argument-invalid.exception.ts b/packages/toolkit/src/exceptions/argument-invalid.exception.ts index cff5f15c..603b2700 100644 --- a/packages/toolkit/src/exceptions/argument-invalid.exception.ts +++ b/packages/toolkit/src/exceptions/argument-invalid.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when an argument is invalid. */ export class ArgumentInvalidException extends Exception {} diff --git a/packages/toolkit/src/exceptions/argument-not-provided.exception.ts b/packages/toolkit/src/exceptions/argument-not-provided.exception.ts index 0134f47a..fcb69685 100644 --- a/packages/toolkit/src/exceptions/argument-not-provided.exception.ts +++ b/packages/toolkit/src/exceptions/argument-not-provided.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when an argument is not provided. */ export class ArgumentNotProvidedException extends Exception {} diff --git a/packages/toolkit/src/exceptions/argument-out-of-range.exception.ts b/packages/toolkit/src/exceptions/argument-out-of-range.exception.ts index 74673adb..76c9b7f7 100644 --- a/packages/toolkit/src/exceptions/argument-out-of-range.exception.ts +++ b/packages/toolkit/src/exceptions/argument-out-of-range.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when an argument is out of range. */ export class ArgumentOutOfRangeException extends Exception {} diff --git a/packages/toolkit/src/exceptions/domain.exception.ts b/packages/toolkit/src/exceptions/domain.exception.ts index c06ea3cb..2497b669 100644 --- a/packages/toolkit/src/exceptions/domain.exception.ts +++ b/packages/toolkit/src/exceptions/domain.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when a domain error occurs. */ export class DomainException extends Exception {} diff --git a/packages/toolkit/src/exceptions/not-implemented.exception.ts b/packages/toolkit/src/exceptions/not-implemented.exception.ts index 9679b461..3eb2f348 100644 --- a/packages/toolkit/src/exceptions/not-implemented.exception.ts +++ b/packages/toolkit/src/exceptions/not-implemented.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when a method or function has not been implemented. */ export class NotImplementedException extends Exception {} diff --git a/packages/toolkit/src/exceptions/timeout.exception.ts b/packages/toolkit/src/exceptions/timeout.exception.ts index 47a1fb75..395194f4 100644 --- a/packages/toolkit/src/exceptions/timeout.exception.ts +++ b/packages/toolkit/src/exceptions/timeout.exception.ts @@ -1,3 +1,4 @@ import { Exception } from '../base-classes/exception.base'; +/** An exception that is thrown when a timeout occurs. */ export class TimeoutException extends Exception {} diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 83db5e47..267f8f2a 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -1,21 +1,37 @@ +export * from './adapters/memory.adapter'; +export * from './base-classes/adapter.base'; +export * from './base-classes/aggregate-root.base'; +export * from './base-classes/command.base'; +export * from './base-classes/domain-event.base'; export * from './base-classes/domain-primitive.base'; export * from './base-classes/entity.base'; +export * from './base-classes/event-bus.base'; +export * from './base-classes/event-handler.base'; export * from './base-classes/exception.base'; export * from './base-classes/factory.base'; export * from './base-classes/mapper.base'; +export * from './base-classes/query.base'; +export * from './base-classes/repository.base'; +export * from './base-classes/server.base'; +export * from './base-classes/task-bus.base'; +export * from './base-classes/task-handler.base'; +export * from './base-classes/task.base'; export * from './base-classes/validatable.base'; export * from './base-classes/value-object.base'; -export * from './decorators/bind.decorator'; export * from './domain-primitives/id.domain-primitive'; +export * from './event-handler.registry'; export * from './exceptions/argument-invalid.exception'; export * from './exceptions/argument-not-provided.exception'; export * from './exceptions/argument-out-of-range.exception'; export * from './exceptions/domain.exception'; export * from './exceptions/not-implemented.exception'; export * from './exceptions/timeout.exception'; +export * from './services/waiter.service'; export * from './streams/buffer-writer.stream'; +export * from './task-handler.registry'; export * from './types/constructor.type'; export * from './types/deep-partial.type'; +export * from './types/instance-type-props.type'; export * from './types/object-literal.type'; export * from './types/require-one.type'; export * from './types/value-of.type'; @@ -28,5 +44,6 @@ export * from './utils/is-empty.util'; export * from './utils/is-object.util'; export * from './utils/is-present.util'; export * from './utils/stream.util'; +export * from './utils/symmetric-difference.util'; +export * from './utils/to-dash-case.util'; export * from './utils/to-stream.util'; -export * from './utils/wait-for.util'; diff --git a/packages/toolkit/src/services/waiter.service.test.ts b/packages/toolkit/src/services/waiter.service.test.ts new file mode 100644 index 00000000..24711630 --- /dev/null +++ b/packages/toolkit/src/services/waiter.service.test.ts @@ -0,0 +1,65 @@ +import { setTimeout } from 'timers/promises'; +import { fnmock, instance, nextTick, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { TimeoutException } from '../exceptions/timeout.exception'; +import { WaiterService } from './waiter.service'; +import type { WaitForCallback } from './waiter.service'; + +describe('WaiterService', function () { + let service: WaiterService; + + beforeEach(function () { + service = new WaiterService(); + }); + + it('resolves when condition is met with default options', async function () { + const callback: WaitForCallback = fnmock(); + const promise = service.waitFor(instance(callback)); + + when(callback()).thenReturn(false).thenReturn(true); + + await nextTick(); + + verify(callback()).once(); + + await setTimeout(30); + + verify(callback()).once(); + + await setTimeout(30); + + verify(callback()).twice(); + + return promise; + }); + + it('resolves when condition is met with custom interval', async function () { + const callback: WaitForCallback = fnmock(); + const promise = service.waitFor(instance(callback), { interval: 30 }); + + when(callback()).thenReturn(false).thenReturn(true); + + await nextTick(); + + verify(callback()).once(); + + await setTimeout(40); + + verify(callback()).twice(); + + return promise; + }); + + it('rejects when condition is not met and timeout triggers', async function () { + const callback: WaitForCallback = fnmock(); + const promise = service.waitFor(instance(callback), { timeout: 100 }); + + when(callback()).thenReturn(false); + + await setTimeout(110); + + verify(callback()).twice(); + + await expect(promise).to.be.rejectedWith(TimeoutException, 'Timeout waiting for condition'); + }); +}); diff --git a/packages/toolkit/src/services/waiter.service.ts b/packages/toolkit/src/services/waiter.service.ts new file mode 100644 index 00000000..1eaaf358 --- /dev/null +++ b/packages/toolkit/src/services/waiter.service.ts @@ -0,0 +1,61 @@ +import { TimeoutException } from '../exceptions/timeout.exception'; + +/** Callback function signature for the `waitFor` method. */ +export type WaitForCallback = () => boolean; + +/** Options for the `waitFor` method. */ +export interface WaitForOptions { + /** + * Timeout in milliseconds. Set to -1 to disable. + * + * @defaultValue -1 + */ + timeout: number; + + /** + * Interval to check the condition in milliseconds. + * + * @defaultValue 50 + */ + interval: number; +} + +const DEFAULT_OPTIONS = { + timeout: -1, + interval: 50, +}; + +/** Service with utility functions for waiting for a given condition to be met. */ +export class WaiterService { + /** Waits for a callback to return true. */ + async waitFor(callback: WaitForCallback, options?: Partial): Promise { + return new Promise((resolve, reject) => { + const opts = Object.assign({}, DEFAULT_OPTIONS, options); + const isTimeoutEnabled = opts.timeout !== -1; + let abortTimer: NodeJS.Timer; + let checkTimer: NodeJS.Timer; + + function check() { + const ret = callback(); + + if (ret) { + clearTimeout(abortTimer); + resolve(); + } else { + checkTimer = setTimeout(check, opts.interval); + } + } + + function abort() { + clearTimeout(checkTimer); + reject(new TimeoutException('Timeout waiting for condition')); + } + + if (isTimeoutEnabled) { + abortTimer = setTimeout(abort, opts.timeout); + } + + process.nextTick(check); + }); + } +} diff --git a/packages/toolkit/src/streams/buffer-writer.stream.ts b/packages/toolkit/src/streams/buffer-writer.stream.ts index 1a53a9f5..f8a49250 100644 --- a/packages/toolkit/src/streams/buffer-writer.stream.ts +++ b/packages/toolkit/src/streams/buffer-writer.stream.ts @@ -1,7 +1,8 @@ import { Writable } from 'stream'; -export type Callback = (error?: Error | null) => void; +type Callback = (error?: Error | null) => void; +/** A stream that writes to a buffer. */ export class BufferWriter extends Writable { buffer: Buffer; diff --git a/packages/toolkit/src/task-handler.registry.test.ts b/packages/toolkit/src/task-handler.registry.test.ts new file mode 100644 index 00000000..16525451 --- /dev/null +++ b/packages/toolkit/src/task-handler.registry.test.ts @@ -0,0 +1,42 @@ +import { anything, capture, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; +import { expect } from 'chai'; +import { TaskHandlerRegistry } from './task-handler.registry'; +import type { TaskBus } from './base-classes/task-bus.base'; +import type { TaskHandler } from './base-classes/task-handler.base'; +import type { Task } from './base-classes/task.base'; + +describe('TaskHandlerRegistry', function () { + let task: Task; + let taskBus: TaskBus; + let taskHandler: TaskHandler; + let taskHandlerManager: TaskHandlerRegistry; + + beforeEach(function () { + task = imock(); + taskBus = imock(); + taskHandler = imock(); + taskHandlerManager = new TaskHandlerRegistry(instance(taskBus)); + }); + + it('should listen for tasks on the bus', function () { + when(taskHandler.forName).thenReturn('task'); + + taskHandlerManager.register(instance(taskHandler)); + + verify(taskBus.subscribe('task', anything())).once(); + }); + + it('should call handle when task is emitted', async function () { + when(taskHandler.forName).thenReturn('task'); + + taskHandlerManager.register(instance(taskHandler)); + + const [forName, callback] = capture(taskBus.subscribe<'task'>).first(); + + expect(forName).to.equal('task'); + + await callback(instance(task)); + + verify(taskHandler.handle(instance(task))).once(); + }); +}); diff --git a/packages/toolkit/src/task-handler.registry.ts b/packages/toolkit/src/task-handler.registry.ts new file mode 100644 index 00000000..50c74a2e --- /dev/null +++ b/packages/toolkit/src/task-handler.registry.ts @@ -0,0 +1,19 @@ +import type { EventHandler } from './base-classes/event-handler.base'; +import type { TaskBus, TaskBusSubscribeHandler, TaskBusTasks } from './base-classes/task-bus.base'; +import type { TaskHandler } from './base-classes/task-handler.base'; + +/** Manages event handlers. */ +export class TaskHandlerRegistry { + constructor(private readonly taskBus: TaskBus) {} + + register(...taskHandlers: TaskHandler[]): void { + taskHandlers.forEach((taskHandler) => this.addEventHandler(taskHandler)); + } + + private addEventHandler(eventHandler: EventHandler): void { + this.taskBus.subscribe( + eventHandler.forName, + eventHandler.handle.bind(eventHandler) as TaskBusSubscribeHandler, + ); + } +} diff --git a/packages/toolkit/src/types/constructor.type.ts b/packages/toolkit/src/types/constructor.type.ts index 2626110a..b3cb5461 100644 --- a/packages/toolkit/src/types/constructor.type.ts +++ b/packages/toolkit/src/types/constructor.type.ts @@ -1,2 +1,3 @@ +/** Generic constructor type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Constructor = new (...args: any[]) => any; diff --git a/packages/toolkit/src/types/deep-partial.type.ts b/packages/toolkit/src/types/deep-partial.type.ts index 4fff1889..d0c1a94d 100644 --- a/packages/toolkit/src/types/deep-partial.type.ts +++ b/packages/toolkit/src/types/deep-partial.type.ts @@ -1,3 +1,4 @@ +/** Type an object to be partially deep. */ export type DeepPartial = { [P in keyof T]?: DeepPartial; }; diff --git a/packages/toolkit/src/types/instance-type-props.type.ts b/packages/toolkit/src/types/instance-type-props.type.ts new file mode 100644 index 00000000..519a3a08 --- /dev/null +++ b/packages/toolkit/src/types/instance-type-props.type.ts @@ -0,0 +1,21 @@ +import type { Constructor } from './constructor.type'; + +/** + * Returns the instance type of each property of a given type. + * + * @example + * ```typescript + * class A {} + * class B {} + * + * const Map = { + * A, + * B, + * }; + * + * type Map = InstanceTypeProps; // Map { A: A; B: B; } + * ``` + */ +export type InstanceTypeProps> = { + [K in keyof T]: InstanceType; +}; diff --git a/packages/toolkit/src/types/object-literal.type.ts b/packages/toolkit/src/types/object-literal.type.ts index 256d7367..847a89dd 100644 --- a/packages/toolkit/src/types/object-literal.type.ts +++ b/packages/toolkit/src/types/object-literal.type.ts @@ -1 +1,2 @@ +/** Helper type to create an object literal type. */ export type ObjectLiteral = Record; diff --git a/packages/toolkit/src/types/require-one.type.ts b/packages/toolkit/src/types/require-one.type.ts index 77d87bc9..166bebc4 100644 --- a/packages/toolkit/src/types/require-one.type.ts +++ b/packages/toolkit/src/types/require-one.type.ts @@ -1,8 +1,10 @@ +/** Require at least one of the given keys. */ export type RequireAtLeastOne = Pick> & { [K in Keys]-?: Required> & Partial>>; }[Keys]; +/** Require only one of the given keys. */ export type RequireOnlyOne = Pick> & { [K in Keys]-?: Required> & Partial, undefined>>; diff --git a/packages/toolkit/src/types/value-of.type.ts b/packages/toolkit/src/types/value-of.type.ts index e9ec02ba..a393ff69 100644 --- a/packages/toolkit/src/types/value-of.type.ts +++ b/packages/toolkit/src/types/value-of.type.ts @@ -1 +1,2 @@ +/** Get the value type of an map type. */ export type ValueOf = ObjectType[ValueType]; diff --git a/packages/toolkit/src/utils/convert-props-to-object.util.ts b/packages/toolkit/src/utils/convert-props-to-object.util.ts index 84c61afc..34aa893d 100644 --- a/packages/toolkit/src/utils/convert-props-to-object.util.ts +++ b/packages/toolkit/src/utils/convert-props-to-object.util.ts @@ -5,9 +5,11 @@ function convertToRaw(item: unknown): unknown { if (isObject(item) && typeof item.toJSON === 'function') { return item.toJSON(); } + return item; } +/** Converts an object to an serializable json object. */ export function convertPropsToObject(props: unknown): ObjectLiteral { if (!isObject(props)) { throw new TypeError(`Unable to convert props type <${typeof props}> to object`); diff --git a/packages/toolkit/src/utils/debug.util.ts b/packages/toolkit/src/utils/debug.util.ts index 52834501..4c2123cf 100644 --- a/packages/toolkit/src/utils/debug.util.ts +++ b/packages/toolkit/src/utils/debug.util.ts @@ -2,6 +2,7 @@ import path from 'path'; import logger from 'debug'; import type { Debugger } from 'debug'; +/** Create a debug instance from a filename. */ export function debug(filename: string): Debugger { const extname = path.extname(filename); const basename = path.basename(filename, extname); diff --git a/packages/toolkit/src/utils/flip-object.util.ts b/packages/toolkit/src/utils/flip-object.util.ts index 48f5b886..80561cbe 100644 --- a/packages/toolkit/src/utils/flip-object.util.ts +++ b/packages/toolkit/src/utils/flip-object.util.ts @@ -1,11 +1,13 @@ -export type AllValues> = { +type AllValues> = { [P in keyof T]: { key: P; value: T[P] }; }[keyof T]; +/** Type an object to be flipped. */ export type FlipObject> = { [P in AllValues['value']]: Extract, { value: P }>['key']; }; +/** Flip the keys and values of an object. */ export function flipObject>(obj: T): FlipObject { const entries = Object.entries(obj) as [keyof T, T[keyof T]][]; diff --git a/packages/toolkit/src/utils/has-key.util.ts b/packages/toolkit/src/utils/has-key.util.ts index bb347338..8143ba88 100644 --- a/packages/toolkit/src/utils/has-key.util.ts +++ b/packages/toolkit/src/utils/has-key.util.ts @@ -1,3 +1,4 @@ +/** Checks if the object has the given key. */ export function hasKey(obj: O, key: K): obj is O & Record { return Object.prototype.hasOwnProperty.call(obj, key); } diff --git a/packages/toolkit/src/utils/interpolate.util.ts b/packages/toolkit/src/utils/interpolate.util.ts index a7c1f32d..9c86401e 100644 --- a/packages/toolkit/src/utils/interpolate.util.ts +++ b/packages/toolkit/src/utils/interpolate.util.ts @@ -1,10 +1,14 @@ import { ArgumentOutOfRangeException } from '../exceptions/argument-out-of-range.exception'; +/** Range of values to interpolate from. */ export interface InterpolateRange { + /** Minimum value of the range. */ min: number; + /** Maximum value of the range. */ max: number; } +/** Interpolate a value from one range to another. */ export function interpolate(value: number, from: InterpolateRange, to: InterpolateRange): number { if (value < from.min || value > from.max) { throw new ArgumentOutOfRangeException( diff --git a/packages/toolkit/src/utils/is-empty.util.ts b/packages/toolkit/src/utils/is-empty.util.ts index 424fa0cf..19378478 100644 --- a/packages/toolkit/src/utils/is-empty.util.ts +++ b/packages/toolkit/src/utils/is-empty.util.ts @@ -1,3 +1,16 @@ +/** + * Checks if the value is an empty value. + * + * @example + * ```typescript + * isEmpty(null); // true + * isEmpty(undefined); // true + * isEmpty(''); // true + * isEmpty([]); // true + * isEmpty({}); // true + * isEmpty([null]); // true + * ``` + */ export function isEmpty(value: unknown): boolean { if (typeof value === 'number' || typeof value === 'boolean') { return false; diff --git a/packages/toolkit/src/utils/is-object.util.ts b/packages/toolkit/src/utils/is-object.util.ts index b60c0fb2..f891909e 100644 --- a/packages/toolkit/src/utils/is-object.util.ts +++ b/packages/toolkit/src/utils/is-object.util.ts @@ -1,6 +1,7 @@ import { isPresent } from './is-present.util'; import type { ObjectLiteral } from '../types/object-literal.type'; +/** Checks if the value is an object. */ export function isObject(obj: unknown): obj is ObjectLiteral { return isPresent(obj) && !Array.isArray(obj) && typeof obj === 'object'; } diff --git a/packages/toolkit/src/utils/is-present.util.ts b/packages/toolkit/src/utils/is-present.util.ts index 899659b3..1f38e86a 100644 --- a/packages/toolkit/src/utils/is-present.util.ts +++ b/packages/toolkit/src/utils/is-present.util.ts @@ -1,3 +1,4 @@ +/** Checks if the value is not null or undefined. */ export function isPresent(value: T): value is NonNullable { return value !== null && value !== undefined; } diff --git a/packages/toolkit/src/utils/stream.util.ts b/packages/toolkit/src/utils/stream.util.ts index d520562b..963a3f4d 100644 --- a/packages/toolkit/src/utils/stream.util.ts +++ b/packages/toolkit/src/utils/stream.util.ts @@ -72,18 +72,37 @@ function writeFn(size: number, method: M) { }; } +/** Read a byte (uint8) from the stream. */ export const readByte = readFn(1, 'readUInt8'); + +/** Read a short (uint16) from the stream. */ export const readShort = readFn(2, 'readUInt16LE'); + +/** Read a word (uint32) from the stream. */ export const readWord = readFn(4, 'readUInt32LE'); + +/** Read a float (float32) from the stream. */ export const readFloat = readFn(4, 'readFloatLE'); + +/** Read a long (uint64) from the stream. */ export const readLong = readFn(8, 'readBigUInt64LE'); +/** Write a byte (uint8) to the stream. */ export const writeByte = writeFn(1, 'writeUInt8'); + +/** Write a short (uint16) to the stream. */ export const writeShort = writeFn(2, 'writeUInt16LE'); + +/** Write a word (uint32) to the stream. */ export const writeWord = writeFn(4, 'writeUInt32LE'); + +/** Write a float (float32) to the stream. */ export const writeFloat = writeFn(4, 'writeFloatLE'); + +/** Write a long (uint64) to the stream. */ export const writeLong = writeFn(8, 'writeBigUInt64LE'); +/** Read a string from the stream. */ export function readString(stream: Readable): string { const length = readByte(stream); @@ -98,6 +117,7 @@ export function readString(stream: Readable): string { return ''; } +/** Write a string to the stream. */ export function writeString(stream: Writable, value: string): void { writeByte(stream, value.length); diff --git a/packages/toolkit/src/utils/symmetric-difference.util.test.ts b/packages/toolkit/src/utils/symmetric-difference.util.test.ts new file mode 100644 index 00000000..d252cbbe --- /dev/null +++ b/packages/toolkit/src/utils/symmetric-difference.util.test.ts @@ -0,0 +1,13 @@ +import { expect } from 'chai'; +import { symmetricDifference } from './symmetric-difference.util'; + +describe('symmetricDifference', function () { + it('should return the symmetric difference', function () { + const firstArray = [1, 2, 3, 4, 5]; + const secondArray = [4, 5, 6]; + + const ret = symmetricDifference(firstArray, secondArray); + + expect(ret).to.be.deep.equal([1, 2, 3, 6]); + }); +}); diff --git a/packages/toolkit/src/utils/symmetric-difference.util.ts b/packages/toolkit/src/utils/symmetric-difference.util.ts new file mode 100644 index 00000000..0e3c15fb --- /dev/null +++ b/packages/toolkit/src/utils/symmetric-difference.util.ts @@ -0,0 +1,10 @@ +/** Returns the symmetric difference of two arrays. */ +export function symmetricDifference(firstArray: T[], secondArray: T[]): T[] { + const firstSet = new Set(firstArray); + const secondSet = new Set(secondArray); + + return [ + ...firstArray.filter((firstItem) => !secondSet.has(firstItem)), + ...secondArray.filter((secondItem) => !firstSet.has(secondItem)), + ]; +} diff --git a/packages/toolkit/src/utils/to-dash-case.util.test.ts b/packages/toolkit/src/utils/to-dash-case.util.test.ts new file mode 100644 index 00000000..6ccf5678 --- /dev/null +++ b/packages/toolkit/src/utils/to-dash-case.util.test.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import { toDashCase } from './to-dash-case.util'; + +describe('toDashCase', function () { + it('should convert camelCase to dash-case', function () { + expect(toDashCase('camelCase')).to.be.equal('camel-case'); + }); + + it('should convert PascalCase to dash-case', function () { + expect(toDashCase('PascalCase')).to.be.equal('pascal-case'); + }); + + it('should convert snake_case to dash-case', function () { + expect(toDashCase('snake_case')).to.be.equal('snake-case'); + }); + + it('should convert UPPER_CASE to dash-case', function () { + expect(toDashCase('UPPER_CASE')).to.be.equal('upper-case'); + }); + + it('should convert dot.case to dash-case', function () { + expect(toDashCase('dot.case')).to.be.equal('dot-case'); + }); + + it('should convert space case to dash-case', function () { + expect(toDashCase('space case')).to.be.equal('space-case'); + }); + + it('should convert Title Case to dash-case', function () { + expect(toDashCase('Title Case')).to.be.equal('title-case'); + }); +}); diff --git a/packages/toolkit/src/utils/to-dash-case.util.ts b/packages/toolkit/src/utils/to-dash-case.util.ts new file mode 100644 index 00000000..f5fb3d8b --- /dev/null +++ b/packages/toolkit/src/utils/to-dash-case.util.ts @@ -0,0 +1,7 @@ +/** Converts a string to dash case. */ +export function toDashCase(str: string): string { + return str + .replace(/[_. ]/g, '-') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); +} diff --git a/packages/toolkit/src/utils/to-stream.util.ts b/packages/toolkit/src/utils/to-stream.util.ts index 373c842c..2969c651 100644 --- a/packages/toolkit/src/utils/to-stream.util.ts +++ b/packages/toolkit/src/utils/to-stream.util.ts @@ -1,5 +1,6 @@ import { Readable } from 'stream'; +/** Convert a buffer to a readable stream. */ export function toStream(buffer: Buffer): Readable { return Readable.from(buffer, { objectMode: false }); } diff --git a/packages/toolkit/src/utils/wait-for.util.test.ts b/packages/toolkit/src/utils/wait-for.util.test.ts deleted file mode 100644 index 9aa183f2..00000000 --- a/packages/toolkit/src/utils/wait-for.util.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { setTimeout } from 'timers/promises'; -import { expect } from 'chai'; -import { TimeoutException } from '../exceptions/timeout.exception'; -import { waitFor } from './wait-for.util'; - -describe('wait-for.util', function () { - it('resolves when condition is met', async function () { - let check = false, - called = false; - const promise = waitFor(() => check); - - void promise.then(() => { - called = true; - }); - - expect(called).to.be.false; - - check = true; - - await setTimeout(50); - - expect(called).to.be.true; - }); - - it('rejects when condition is not met and timeout triggers', async function () { - let called = false; - const check = false; - const promise = waitFor(() => check, { timeout: 100 }); - - void promise.catch((e) => { - expect(e).to.be.instanceof(TimeoutException); - called = true; - }); - - await setTimeout(50); - - expect(called).to.be.false; - - await setTimeout(50); - - expect(called).to.be.true; - }); -}); diff --git a/packages/toolkit/src/utils/wait-for.util.ts b/packages/toolkit/src/utils/wait-for.util.ts deleted file mode 100644 index 3c717259..00000000 --- a/packages/toolkit/src/utils/wait-for.util.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { TimeoutException } from '../exceptions/timeout.exception'; - -export type WaitForCallback = () => boolean; - -export interface WaitForOptions { - timeout: number; - interval: number; -} - -const DEFAULT_OPTIONS = { - timeout: 2 ** 31 - 1, - interval: 50, -}; - -export function waitFor(callback: WaitForCallback, options?: Partial): Promise { - return new Promise((resolve, reject) => { - const opts = Object.assign({}, DEFAULT_OPTIONS, options); - - function check() { - const ret = callback(); - - if (ret) { - clearTimeout(abortTimer); - resolve(); - } else { - checkTimer = setTimeout(check, opts.interval); - } - } - - function abort() { - clearTimeout(checkTimer); - reject(new TimeoutException('Timeout waiting for condition')); - } - - const abortTimer = setTimeout(abort, opts.timeout); - let checkTimer: NodeJS.Timer; - - process.nextTick(check); - }); -} diff --git a/packages/transport-tcp/package.json b/packages/transport-tcp/package.json index 2fc46b08..f36d30e5 100644 --- a/packages/transport-tcp/package.json +++ b/packages/transport-tcp/package.json @@ -79,8 +79,8 @@ "dependencies": { "@agnoc/schemas-tcp": "^0.16.0", "@agnoc/toolkit": "^0.18.0-next.0", - "debug": "^4.3.4", - "tiny-typed-emitter": "^2.1.0", + "emittery": "^0.13.1", + "protobufjs": "^7.2.2", "tslib": "^2.5.0" }, "devDependencies": { diff --git a/packages/transport-tcp/src/constants/payloads.constant.ts b/packages/transport-tcp/src/constants/payloads.constant.ts index d59db162..26d383bc 100644 --- a/packages/transport-tcp/src/constants/payloads.constant.ts +++ b/packages/transport-tcp/src/constants/payloads.constant.ts @@ -55,6 +55,7 @@ import type { IDEVICE_MAPID_WORK_STATUS_PUSH_REQ, IDEVICE_MOP_FLOOR_CLEAN_REQ, IDEVICE_MOP_FLOOR_CLEAN_RSP, + IDEVICE_OFFLINE_CMD, IDEVICE_ORDERLIST_DELETE_REQ, IDEVICE_ORDERLIST_DELETE_RSP, IDEVICE_ORDERLIST_GETTING_REQ, @@ -98,7 +99,7 @@ import type { IUSER_SET_DEVICE_QUIETHOURS_RSP, } from '@agnoc/schemas-tcp'; -interface PayloadObjectMap { +interface PayloadDataMap { CLIENT_HEARTBEAT_REQ: ICLIENT_HEARTBEAT_REQ; CLIENT_HEARTBEAT_RSP: ICLIENT_HEARTBEAT_RSP; CLIENT_IDLE_TIMEOUT: ICLIENT_IDLE_TIMEOUT; @@ -157,6 +158,7 @@ interface PayloadObjectMap { DEVICE_MAPID_WORK_STATUS_PUSH_REQ: IDEVICE_MAPID_WORK_STATUS_PUSH_REQ; DEVICE_MOP_FLOOR_CLEAN_REQ: IDEVICE_MOP_FLOOR_CLEAN_REQ; DEVICE_MOP_FLOOR_CLEAN_RSP: IDEVICE_MOP_FLOOR_CLEAN_RSP; + DEVICE_OFFLINE_CMD: IDEVICE_OFFLINE_CMD; DEVICE_ORDERLIST_DELETE_REQ: IDEVICE_ORDERLIST_DELETE_REQ; DEVICE_ORDERLIST_DELETE_RSP: IDEVICE_ORDERLIST_DELETE_RSP; DEVICE_ORDERLIST_GETTING_REQ: IDEVICE_ORDERLIST_GETTING_REQ; @@ -200,6 +202,6 @@ interface PayloadObjectMap { USER_SET_DEVICE_QUIETHOURS_RSP: IUSER_SET_DEVICE_QUIETHOURS_RSP; } -export type PayloadObject = PayloadObjectMap[PayloadObjectName]; -export type PayloadObjectName = keyof PayloadObjectMap & OPNameLiteral; -export type PayloadObjectFrom = PayloadObjectMap[Name]; +export type PayloadData = PayloadDataMap[PayloadDataName]; +export type PayloadDataName = keyof PayloadDataMap & OPNameLiteral; +export type PayloadDataFrom = PayloadDataMap[Name]; diff --git a/packages/transport-tcp/src/factories/packet.factory.test.ts b/packages/transport-tcp/src/factories/packet.factory.test.ts new file mode 100644 index 00000000..c541430f --- /dev/null +++ b/packages/transport-tcp/src/factories/packet.factory.test.ts @@ -0,0 +1,45 @@ +import { ID } from '@agnoc/toolkit'; +import { expect } from 'chai'; +import { PacketSequence } from '../domain-primitives/packet-sequence.domain-primitive'; +import { givenSomePacketProps } from '../test-support'; +import { Packet } from '../value-objects/packet.value-object'; +import { PacketFactory } from './packet.factory'; + +describe('PacketFactory', function () { + let packetFactory: PacketFactory; + + beforeEach(function () { + packetFactory = new PacketFactory(); + }); + + it('should create a packet from some props', function () { + const object = { result: 0, body: { deviceTime: 1606129555 } }; + const props = { userId: new ID(1), deviceId: new ID(2) }; + const packet = packetFactory.create('DEVICE_GETTIME_RSP', object, props); + + expect(packet).to.be.instanceOf(Packet); + expect(packet.ctype).to.equal(2); + expect(packet.flow).to.equal(0); + expect(packet.userId).to.equal(props.deviceId); + expect(packet.deviceId).to.equal(props.userId); + expect(packet.sequence).to.be.instanceOf(PacketSequence); + expect(packet.payload.opcode.name).to.equal('DEVICE_GETTIME_RSP'); + expect(packet.payload.data).to.equal(object); + }); + + it('should create a packet from another packet', function () { + const sourcePacketProps = { ...givenSomePacketProps(), flow: 0 }; + const sourcePacket = new Packet(sourcePacketProps); + const object = { result: 0, body: { deviceTime: 1606129555 } }; + const packet = packetFactory.create('DEVICE_GETTIME_RSP', object, sourcePacket); + + expect(packet).to.be.instanceOf(Packet); + expect(packet.ctype).to.equal(sourcePacketProps.ctype); + expect(packet.flow).to.equal(1); + expect(packet.userId).to.equal(sourcePacketProps.deviceId); + expect(packet.deviceId).to.equal(sourcePacketProps.userId); + expect(packet.sequence).to.be.equal(sourcePacketProps.sequence); + expect(packet.payload.opcode.name).to.equal('DEVICE_GETTIME_RSP'); + expect(packet.payload.data).to.equal(object); + }); +}); diff --git a/packages/transport-tcp/src/factories/packet.factory.ts b/packages/transport-tcp/src/factories/packet.factory.ts new file mode 100644 index 00000000..9879a79a --- /dev/null +++ b/packages/transport-tcp/src/factories/packet.factory.ts @@ -0,0 +1,55 @@ +import { OPCode } from '../domain-primitives/opcode.domain-primitive'; +import { PacketSequence } from '../domain-primitives/packet-sequence.domain-primitive'; +import { Packet } from '../value-objects/packet.value-object'; +import { Payload } from '../value-objects/payload.value-object'; +import type { PayloadDataFrom, PayloadDataName } from '../constants/payloads.constant'; +import type { Factory, ID } from '@agnoc/toolkit'; + +/** The props to create a packet. */ +export interface CreatePacketProps { + userId: ID; + deviceId: ID; +} + +/** A factory to create packets. */ +export class PacketFactory implements Factory { + /** + * Creates a packet from a payload object. + * + * To use this method you must provider a `deviceId` and `userId` in the `props` object, + * or a `Packet` object in the `packet` argument to copy the packet props from. + */ + create( + name: Name, + object: PayloadDataFrom, + props: CreatePacketProps, + ): Packet; + create(name: Name, object: PayloadDataFrom, packet: Packet): Packet; + create( + name: Name, + object: PayloadDataFrom, + propsOrPacket: CreatePacketProps | Packet, + ): Packet { + if (propsOrPacket instanceof Packet) { + return new Packet({ + ctype: propsOrPacket.ctype, + flow: propsOrPacket.flow + 1, + // This swap is intended. + userId: propsOrPacket.deviceId, + deviceId: propsOrPacket.userId, + sequence: propsOrPacket.sequence, + payload: new Payload({ opcode: OPCode.fromName(name), data: object }), + }); + } + + return new Packet({ + ctype: 2, + flow: 0, + // This swap is intended. + userId: propsOrPacket.deviceId, + deviceId: propsOrPacket.userId, + sequence: PacketSequence.generate(), + payload: new Payload({ opcode: OPCode.fromName(name), data: object }), + }); + } +} diff --git a/packages/transport-tcp/src/index.ts b/packages/transport-tcp/src/index.ts index 3de6b966..f159992b 100644 --- a/packages/transport-tcp/src/index.ts +++ b/packages/transport-tcp/src/index.ts @@ -2,17 +2,18 @@ export * from './constants/opcodes.constant'; export * from './constants/payloads.constant'; export * from './decoders/area.decoder'; export * from './decoders/charge-position.decoder'; -export * from './utils/get-custom-decoders.util'; export * from './decoders/map.decoder'; +export * from './decoders/map.interface'; export * from './decoders/robot-position.decoder'; export * from './domain-primitives/opcode.domain-primitive'; export * from './domain-primitives/packet-sequence.domain-primitive'; -export * from './decoders/map.interface'; +export * from './factories/packet.factory'; export * from './mappers/packet.mapper'; export * from './mappers/payload.mapper'; -export * from './services/payload-object-parser.service'; +export * from './packet.server'; export * from './packet.socket'; +export * from './services/payload-data-parser.service'; +export * from './utils/get-custom-decoders.util'; export * from './utils/get-protobuf-root.util'; export * from './value-objects/packet.value-object'; export * from './value-objects/payload.value-object'; -export * from './packet.server'; diff --git a/packages/transport-tcp/src/mappers/packet.mapper.test.ts b/packages/transport-tcp/src/mappers/packet.mapper.test.ts index 118c741f..eca05bda 100644 --- a/packages/transport-tcp/src/mappers/packet.mapper.test.ts +++ b/packages/transport-tcp/src/mappers/packet.mapper.test.ts @@ -84,7 +84,7 @@ describe('PacketMapper', function () { sequence: PacketSequence.fromString('7a479a0fbb978c12'), payload: new Payload({ opcode: OPCode.fromName('DEVICE_GETTIME_RSP'), - object: { result: 1, body: { deviceTime: 1 } }, + data: { result: 1, body: { deviceTime: 1 } }, }), }); diff --git a/packages/transport-tcp/src/mappers/packet.mapper.ts b/packages/transport-tcp/src/mappers/packet.mapper.ts index 39853b3f..38797039 100644 --- a/packages/transport-tcp/src/mappers/packet.mapper.ts +++ b/packages/transport-tcp/src/mappers/packet.mapper.ts @@ -16,7 +16,7 @@ import { OPCode } from '../domain-primitives/opcode.domain-primitive'; import { PacketSequence } from '../domain-primitives/packet-sequence.domain-primitive'; import { Packet } from '../value-objects/packet.value-object'; import type { PayloadMapper } from './payload.mapper'; -import type { PayloadObjectName } from '../constants/payloads.constant'; +import type { PayloadDataName } from '../constants/payloads.constant'; import type { Mapper } from '@agnoc/toolkit'; /** Mapper for converting packets to and from buffers. */ @@ -24,7 +24,7 @@ export class PacketMapper implements Mapper { constructor(private readonly payloadMapper: PayloadMapper) {} /** Converts a buffer to a packet. */ - toDomain(data: Buffer): Packet { + toDomain(data: Buffer): Packet { const stream = toStream(data); const size = readWord(stream); diff --git a/packages/transport-tcp/src/mappers/payload.mapper.test.ts b/packages/transport-tcp/src/mappers/payload.mapper.test.ts index f17a4e78..91e9a63e 100644 --- a/packages/transport-tcp/src/mappers/payload.mapper.test.ts +++ b/packages/transport-tcp/src/mappers/payload.mapper.test.ts @@ -4,19 +4,19 @@ import { expect } from 'chai'; import { OPCode } from '../domain-primitives/opcode.domain-primitive'; import { Payload } from '../value-objects/payload.value-object'; import { PayloadMapper } from './payload.mapper'; -import type { Decoder, Encoder, PayloadObjectParserService } from '../services/payload-object-parser.service'; +import type { Decoder, Encoder, PayloadDataParserService } from '../services/payload-data-parser.service'; describe('PayloadMapper', function () { let decoder: Decoder; let encoder: Encoder; - let payloadObjectParserService: PayloadObjectParserService; + let payloadDataParserService: PayloadDataParserService; let payloadMapper: PayloadMapper; beforeEach(function () { decoder = fnmock(); encoder = fnmock(); - payloadObjectParserService = imock(); - payloadMapper = new PayloadMapper(instance(payloadObjectParserService)); + payloadDataParserService = imock(); + payloadMapper = new PayloadMapper(instance(payloadDataParserService)); }); describe('#toDomain()', function () { @@ -25,14 +25,14 @@ describe('PayloadMapper', function () { const opcode = OPCode.fromName('CLIENT_HEARTBEAT_REQ'); const object = { foo: 'bar' }; - when(payloadObjectParserService.getDecoder(anything())).thenReturn(instance(decoder)); + when(payloadDataParserService.getDecoder(anything())).thenReturn(instance(decoder)); when(decoder(anything())).thenReturn(object); const payload = payloadMapper.toDomain(buffer, opcode); expect(payload).to.be.instanceOf(Payload); expect(payload.opcode).to.equal(opcode); - expect(payload.object).to.equal(object); + expect(payload.data).to.equal(object); verify(decoder(buffer)).once(); }); @@ -41,7 +41,7 @@ describe('PayloadMapper', function () { const buffer = Buffer.from('test'); const opcode = OPCode.fromName('CLIENT_HEARTBEAT_REQ'); - when(payloadObjectParserService.getDecoder(anything())).thenReturn(undefined); + when(payloadDataParserService.getDecoder(anything())).thenReturn(undefined); expect(() => payloadMapper.toDomain(buffer, opcode)).to.throw( ArgumentInvalidException, @@ -54,10 +54,10 @@ describe('PayloadMapper', function () { it('should create a buffer from a payload', function () { const opcode = OPCode.fromName('CLIENT_HEARTBEAT_REQ'); const object = { foo: 'bar' }; - const payload = new Payload({ opcode: opcode, object: object }); + const payload = new Payload({ opcode, data: object }); const buffer = Buffer.from('test'); - when(payloadObjectParserService.getEncoder(anything())).thenReturn(instance(encoder)); + when(payloadDataParserService.getEncoder(anything())).thenReturn(instance(encoder)); when(encoder(anything())).thenReturn(buffer); const ret = payloadMapper.fromDomain(payload); @@ -70,13 +70,13 @@ describe('PayloadMapper', function () { it('should throw an error when the encoder does not exist', function () { const opcode = OPCode.fromName('CLIENT_HEARTBEAT_REQ'); const object = { foo: 'bar' }; - const payload = new Payload({ opcode: opcode, object: object }); + const payload = new Payload({ opcode, data: object }); - when(payloadObjectParserService.getEncoder(anything())).thenReturn(undefined); + when(payloadDataParserService.getEncoder(anything())).thenReturn(undefined); expect(() => payloadMapper.fromDomain(payload)).to.throw( ArgumentInvalidException, - `Encoder not found for opcode 'CLIENT_HEARTBEAT_REQ' while creating payload from object: {"foo":"bar"}`, + `Encoder not found for opcode 'CLIENT_HEARTBEAT_REQ' while creating payload from data: {"foo":"bar"}`, ); }); }); diff --git a/packages/transport-tcp/src/mappers/payload.mapper.ts b/packages/transport-tcp/src/mappers/payload.mapper.ts index 05b2e532..ba8a6402 100644 --- a/packages/transport-tcp/src/mappers/payload.mapper.ts +++ b/packages/transport-tcp/src/mappers/payload.mapper.ts @@ -1,15 +1,15 @@ import { ArgumentInvalidException } from '@agnoc/toolkit'; import { Payload } from '../value-objects/payload.value-object'; -import type { PayloadObjectName } from '../constants/payloads.constant'; +import type { PayloadDataName } from '../constants/payloads.constant'; import type { OPCode } from '../domain-primitives/opcode.domain-primitive'; -import type { PayloadObjectParserService } from '../services/payload-object-parser.service'; +import type { PayloadDataParserService } from '../services/payload-data-parser.service'; import type { Mapper } from '@agnoc/toolkit'; export class PayloadMapper implements Mapper { - constructor(private readonly payloadObjectParserService: PayloadObjectParserService) {} + constructor(private readonly payloadDataParserService: PayloadDataParserService) {} - toDomain(buffer: Buffer, opcode: OPCode): Payload { - const decoder = this.payloadObjectParserService.getDecoder(opcode.name as Name); + toDomain(buffer: Buffer, opcode: OPCode): Payload { + const decoder = this.payloadDataParserService.getDecoder(opcode.name as Name); if (!decoder) { throw new ArgumentInvalidException( @@ -17,25 +17,25 @@ export class PayloadMapper implements Mapper { ); } - const object = decoder(buffer); + const data = decoder(buffer); return new Payload({ - opcode: opcode, - object, + opcode, + data, }); } - fromDomain(payload: Payload): Buffer { - const encoder = this.payloadObjectParserService.getEncoder(payload.opcode.name as Name); + fromDomain(payload: Payload): Buffer { + const encoder = this.payloadDataParserService.getEncoder(payload.opcode.name as Name); if (!encoder) { throw new ArgumentInvalidException( - `Encoder not found for opcode '${payload.opcode.name}' while creating payload from object: ${JSON.stringify( - payload.object, + `Encoder not found for opcode '${payload.opcode.name}' while creating payload from data: ${JSON.stringify( + payload.data, )}`, ); } - return encoder(payload.object); + return encoder(payload.data); } } diff --git a/packages/transport-tcp/src/packet.server.test.ts b/packages/transport-tcp/src/packet.server.test.ts index be311f02..a49b2840 100644 --- a/packages/transport-tcp/src/packet.server.test.ts +++ b/packages/transport-tcp/src/packet.server.test.ts @@ -29,7 +29,7 @@ describe('PacketServer', function () { it('should receive connections', function (done) { const socket = new Socket(); - packetServer.once('connection', (socket) => { + void packetServer.once('connection').then((socket) => { expect(socket).to.be.instanceof(PacketSocket); done(); }); @@ -43,12 +43,12 @@ describe('PacketServer', function () { }); it('should close the server', function (done) { - packetServer.once('close', () => { + void packetServer.once('close').then(() => { expect(packetServer.isListening).to.be.false; done(); }); - packetServer.once('listening', () => { + void packetServer.once('listening').then(() => { void packetServer.close(); }); @@ -65,7 +65,7 @@ describe('PacketServer', function () { describe('#listen()', function () { it('should listen by port', function (done) { - packetServer.once('listening', () => { + void packetServer.once('listening').then(() => { expect(packetServer.isListening).to.be.true; expect(packetServer.address).to.be.an.instanceof(Object); done(); @@ -75,7 +75,7 @@ describe('PacketServer', function () { }); it('should listen by host and port', function (done) { - packetServer.once('listening', () => { + void packetServer.once('listening').then(() => { expect(packetServer.isListening).to.be.true; expect(packetServer.address).to.be.an.instanceof(Object); done(); @@ -85,7 +85,7 @@ describe('PacketServer', function () { }); it('should listen by options', function (done) { - packetServer.once('listening', () => { + void packetServer.once('listening').then(() => { expect(packetServer.isListening).to.be.true; expect(packetServer.address).to.be.an.instanceof(Object); done(); diff --git a/packages/transport-tcp/src/packet.server.ts b/packages/transport-tcp/src/packet.server.ts index 6275d7c3..992bb8a1 100644 --- a/packages/transport-tcp/src/packet.server.ts +++ b/packages/transport-tcp/src/packet.server.ts @@ -1,5 +1,5 @@ import { Server } from 'net'; -import { TypedEmitter } from 'tiny-typed-emitter'; +import Emittery from 'emittery'; import { PacketSocket } from './packet.socket'; import type { PacketMapper } from './mappers/packet.mapper'; import type { AddressInfo, ListenOptions, Socket, DropArgument } from 'net'; @@ -7,24 +7,24 @@ import type { AddressInfo, ListenOptions, Socket, DropArgument } from 'net'; /** Events emitted by the {@link PacketServer}. */ export interface PacketServerEvents { /** Emits a {@link PacketSocket} when a new connection is established. */ - connection: (socket: PacketSocket) => void; + connection: PacketSocket; /** Emits an error when an error occurs. */ - error: (err: Error) => void; + error: Error; /** Emits when the server has been bound after calling `server.listen`. */ - listening: () => void; + listening: undefined; /** Emits when the server closes. */ - close: () => void; + close: undefined; /** Emits when a packet is dropped due to a full socket buffer. */ - drop: (data?: DropArgument) => void; + drop: DropArgument | undefined; } /** Server that emits `PacketSockets`. */ -export class PacketServer extends TypedEmitter { - private server: Server; +export class PacketServer extends Emittery { + private readonly sockets = new Set(); + private readonly server = new Server(); constructor(private readonly packetMapper: PacketMapper) { super(); - this.server = new Server(); this.addListeners(); } @@ -66,22 +66,32 @@ export class PacketServer extends TypedEmitter { resolve(); }); + + this.sockets.forEach((socket) => void socket.end()); }); } private onConnection(socket: Socket): void { - const client = new PacketSocket(this.packetMapper, socket); + const packetSocket = new PacketSocket(this.packetMapper, socket); + + this.sockets.add(packetSocket); - this.emit('connection', client); + packetSocket.on('close', () => { + this.sockets.delete(packetSocket); + }); + + void this.emit('connection', packetSocket); } private addListeners(): void { this.server.on('connection', this.onConnection.bind(this)); - this.server.on('listening', () => this.emit('listening')); - this.server.on('close', () => this.emit('close')); + this.server.on('listening', () => void this.emit('listening')); + this.server.on('close', () => { + void this.emit('close'); + }); /* istanbul ignore next - unable to test */ - this.server.on('error', (error) => this.emit('error', error)); + this.server.on('error', (error) => void this.emit('error', error)); /* istanbul ignore next - unable to test */ - this.server.on('drop', (data) => this.emit('drop', data)); + this.server.on('drop', (data) => void this.emit('drop', data)); } } diff --git a/packages/transport-tcp/src/packet.socket.test.ts b/packages/transport-tcp/src/packet.socket.test.ts index c9d092ae..2081ff95 100644 --- a/packages/transport-tcp/src/packet.socket.test.ts +++ b/packages/transport-tcp/src/packet.socket.test.ts @@ -1,4 +1,4 @@ -import { Server } from 'net'; +import { Server, Socket } from 'net'; import { Duplex } from 'stream'; import { setTimeout } from 'timers/promises'; import { DomainException } from '@agnoc/toolkit'; @@ -10,7 +10,7 @@ import { Packet } from './value-objects/packet.value-object'; import type { PacketMapper } from './mappers/packet.mapper'; import type { AddressInfo } from 'net'; -describe('packet.socket', function () { +describe('PacketSocket', function () { let packetMapper: PacketMapper; let packetSocket: PacketSocket; let server: Server; @@ -22,6 +22,10 @@ describe('packet.socket', function () { }); afterEach(function (done) { + if (packetSocket.connected) { + void packetSocket.end(); + } + if (server.listening) { server.close(done); } else { @@ -35,6 +39,9 @@ describe('packet.socket', function () { expect(packetSocket.localPort).to.be.undefined; expect(packetSocket.remoteAddress).to.be.undefined; expect(packetSocket.remotePort).to.be.undefined; + expect(packetSocket.toString()).to.be.equal('unknown:0::unknown:0'); + expect(packetSocket.connecting).to.be.false; + expect(packetSocket.connected).to.be.false; }); it('should connect to a server', function (done) { @@ -42,6 +49,7 @@ describe('packet.socket', function () { void packetSocket.connect((server.address() as AddressInfo).port); expect(packetSocket.connecting).to.be.true; + expect(packetSocket.connected).to.be.false; }); packetSocket.once('connect', () => { @@ -49,8 +57,10 @@ describe('packet.socket', function () { expect(packetSocket.localPort).to.be.a('number'); expect(packetSocket.remoteAddress).to.be.a('string'); expect(packetSocket.remotePort).to.be.a('number'); + expect(packetSocket.toString()).to.be.not.equal('unknown:0::unknown:0'); expect(packetSocket.connecting).to.be.false; - packetSocket.end(); + expect(packetSocket.connected).to.be.true; + void packetSocket.end(); }); packetSocket.once('end', done); @@ -77,7 +87,7 @@ describe('packet.socket', function () { }); packetSocket.once('connect', () => { - packetSocket.end(packet); + void packetSocket.end(packet); }); server.listen(0); @@ -140,7 +150,7 @@ describe('packet.socket', function () { done(); }); - packetSocket.write(packet); + void packetSocket.write(packet); }); it('should not try to end when is not connected', function (done) { @@ -150,7 +160,7 @@ describe('packet.socket', function () { done(); }); - packetSocket.end(); + void packetSocket.end(); }); it('should throw an error when unable to map the packet', function (done) { @@ -203,7 +213,7 @@ describe('packet.socket', function () { server.listen(0); }); - it('should wrap an existing socket', function (done) { + it('should wrap an existing connected socket', function (done) { const buffer = givenAPacketBuffer(); const packet = new Packet(givenSomePacketProps()); @@ -217,7 +227,15 @@ describe('packet.socket', function () { server.once('connection', (socket) => { const packetSocketServer = new PacketSocket(instance(packetMapper), socket); - packetSocketServer.end(packet); + expect(packetSocketServer.localAddress).to.be.a('string'); + expect(packetSocketServer.localPort).to.be.a('number'); + expect(packetSocketServer.remoteAddress).to.be.a('string'); + expect(packetSocketServer.remotePort).to.be.a('number'); + expect(packetSocketServer.toString()).to.be.not.equal('unknown:0::unknown:0'); + expect(packetSocketServer.connecting).to.be.false; + expect(packetSocketServer.connected).to.be.true; + + void packetSocketServer.end(packet); }); packetSocket.once('data', (data) => { @@ -228,6 +246,18 @@ describe('packet.socket', function () { server.listen(0); }); + it('should wrap an existing socket', function () { + packetSocket = new PacketSocket(instance(packetMapper), new Socket()); + + expect(packetSocket.localAddress).to.be.undefined; + expect(packetSocket.localPort).to.be.undefined; + expect(packetSocket.remoteAddress).to.be.undefined; + expect(packetSocket.remotePort).to.be.undefined; + expect(packetSocket.toString()).to.be.equal('unknown:0::unknown:0'); + expect(packetSocket.connecting).to.be.false; + expect(packetSocket.connected).to.be.false; + }); + describe('#connect()', function () { it('should connect to a server by options', function (done) { server.once('listening', () => { @@ -237,7 +267,7 @@ describe('packet.socket', function () { }); packetSocket.once('connect', () => { - packetSocket.end(); + void packetSocket.end(); }); packetSocket.once('end', done); @@ -251,7 +281,7 @@ describe('packet.socket', function () { }); packetSocket.once('connect', () => { - packetSocket.end(); + void packetSocket.end(); }); packetSocket.once('end', done); @@ -267,13 +297,202 @@ describe('packet.socket', function () { }); packetSocket.once('connect', () => { - packetSocket.end(); + void packetSocket.end(); }); packetSocket.once('end', done); server.listen(0); }); + + it('should throw an error when the socket is already connected', function (done) { + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + packetSocket.once('connect', async () => { + await expect(packetSocket.connect((server.address() as AddressInfo).port)).to.be.rejectedWith( + DomainException, + 'Socket is already connected', + ); + + await packetSocket.end(); + + done(); + }); + + server.listen(0); + }); + }); + + describe('#write()', function () { + it('should write packets with encoding and callback', function (done) { + const buffer = givenAPacketBuffer(); + const packet = new Packet(givenSomePacketProps()); + + when(packetMapper.fromDomain(anything())).thenReturn(buffer); + + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + server.once('connection', (socket) => { + socket.once('data', (data) => { + expect(Buffer.compare(data, buffer)).to.be.equal(0); + verify(packetMapper.fromDomain(packet)).once(); + socket.end(); + }); + }); + + packetSocket.once('connect', () => { + packetSocket.write(packet, 'utf8', done); + }); + + server.listen(0); + }); + + it('should write packets with callback', function (done) { + const buffer = givenAPacketBuffer(); + const packet = new Packet(givenSomePacketProps()); + + when(packetMapper.fromDomain(anything())).thenReturn(buffer); + + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + server.once('connection', (socket) => { + socket.once('data', (data) => { + expect(Buffer.compare(data, buffer)).to.be.equal(0); + verify(packetMapper.fromDomain(packet)).once(); + socket.end(); + }); + }); + + packetSocket.once('connect', () => { + packetSocket.write(packet, done); + }); + + server.listen(0); + }); + + it('should write packets as a promise', function (done) { + const buffer = givenAPacketBuffer(); + const packet = new Packet(givenSomePacketProps()); + + when(packetMapper.fromDomain(anything())).thenReturn(buffer); + + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + server.once('connection', (socket) => { + socket.once('data', (data) => { + expect(Buffer.compare(data, buffer)).to.be.equal(0); + verify(packetMapper.fromDomain(packet)).once(); + socket.end(); + }); + }); + + packetSocket.once('connect', async () => { + await packetSocket.write(packet); + done(); + }); + + server.listen(0); + }); + + it('should reject the write when it fails', function (done) { + const packet = new Packet(givenSomePacketProps()); + + packetSocket.once('error', () => { + // noop + }); + + packetSocket.write(packet).catch((error) => { + expect(error).to.be.an.instanceOf(DomainException); + expect((error as Error).message).to.be.equal('Socket is not connected'); + done(); + }); + }); + }); + + describe('#end()', function () { + it('should end socket with packet and callback', function (done) { + const buffer = givenAPacketBuffer(); + const packet = new Packet(givenSomePacketProps()); + + when(packetMapper.fromDomain(anything())).thenReturn(buffer); + + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + server.once('connection', (socket) => { + socket.once('data', (data) => { + expect(Buffer.compare(data, buffer)).to.be.equal(0); + verify(packetMapper.fromDomain(packet)).once(); + socket.end(); + }); + }); + + packetSocket.once('connect', () => { + packetSocket.end(packet, done); + }); + + server.listen(0); + }); + + it('should end socket with callback', function (done) { + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + packetSocket.once('connect', () => { + packetSocket.end(done); + }); + + server.listen(0); + }); + + it('should end socket with packet as a promise', function (done) { + const buffer = givenAPacketBuffer(); + const packet = new Packet(givenSomePacketProps()); + + when(packetMapper.fromDomain(anything())).thenReturn(buffer); + + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + server.once('connection', (socket) => { + socket.once('data', (data) => { + expect(Buffer.compare(data, buffer)).to.be.equal(0); + verify(packetMapper.fromDomain(packet)).once(); + socket.end(); + }); + }); + + packetSocket.once('connect', async () => { + await packetSocket.end(packet); + done(); + }); + + server.listen(0); + }); + + it('should end socket as a promise', function (done) { + server.once('listening', () => { + void packetSocket.connect((server.address() as AddressInfo).port); + }); + + packetSocket.once('connect', async () => { + await packetSocket.end(); + done(); + }); + + server.listen(0); + }); }); }); diff --git a/packages/transport-tcp/src/packet.socket.ts b/packages/transport-tcp/src/packet.socket.ts index 1864ad0a..f82bc4e2 100644 --- a/packages/transport-tcp/src/packet.socket.ts +++ b/packages/transport-tcp/src/packet.socket.ts @@ -1,6 +1,6 @@ import { Socket } from 'net'; import { Duplex } from 'stream'; -import { DomainException } from '@agnoc/toolkit'; +import { debug, DomainException } from '@agnoc/toolkit'; import type { PacketMapper } from './mappers/packet.mapper'; import type { Packet } from './value-objects/packet.value-object'; import type { SocketConnectOpts } from 'net'; @@ -8,7 +8,7 @@ import type { SocketConnectOpts } from 'net'; /** Events emitted by the {@link PacketSocket}. */ export interface PacketSocketEvents { /** Emits a {@link Packet} when `data` is received. */ - data: (packet: Packet) => void; + data: (packet: Packet) => void | Promise; connect: () => void; close: (hasError: boolean) => void; drain: () => void; @@ -22,6 +22,7 @@ export interface PacketSocketEvents { /** Socket that parses and serializes packets sent through a socket. */ export class PacketSocket extends Duplex { + private readonly debug = (msg: string, ...args: string[]) => debug(__filename).extend(this.toString())(msg, ...args); private socket?: Socket; private readingPaused = false; @@ -30,6 +31,7 @@ export class PacketSocket extends Duplex { if (socket) { this.wrapSocket(socket); + this.debug('new socket'); } } @@ -38,12 +40,16 @@ export class PacketSocket extends Duplex { connect(port: number, host: string): Promise; connect(path: string): Promise; connect(options: SocketConnectOpts): Promise; - connect(portOrPathOrOptions: number | string | SocketConnectOpts, host?: string): Promise { + async connect(portOrPathOrOptions: number | string | SocketConnectOpts, host?: string): Promise { + if (this.socket) { + throw new DomainException('Socket is already connected'); + } + const socket = new Socket(); this.wrapSocket(socket); - return new Promise((resolve) => { + await new Promise((resolve) => { if (typeof portOrPathOrOptions === 'number' && host) { return socket.connect(portOrPathOrOptions, host, resolve); } @@ -59,6 +65,60 @@ export class PacketSocket extends Duplex { return socket.connect(portOrPathOrOptions, resolve); }); + + this.debug('connected'); + } + + /** Writes a packet to the socket. */ + override write(packet: Packet, encoding: BufferEncoding, cb: (error: Error | null | undefined) => void): boolean; + override write(packet: Packet, cb: WriteCallback): boolean; + override write(packet: Packet): Promise; + override write( + packet: Packet, + encodingOrCb?: BufferEncoding | WriteCallback, + cb?: WriteCallback, + ): boolean | Promise { + if (cb) { + return super.write(packet, encodingOrCb as BufferEncoding, cb); + } + + if (encodingOrCb) { + return super.write(packet, encodingOrCb as WriteCallback); + } + + return new Promise((resolve, reject) => { + super.write(packet, (err) => { + if (err) { + return reject(err); + } + + resolve(); + }); + }); + } + + /** Closes the socket. */ + override end(packet: Packet, cb: EndCallback): this; + override end(packet: Packet): Promise; + override end(cb: EndCallback): this; + override end(): Promise; + override end(packetOrCb?: Packet | EndCallback, cb?: EndCallback): this | Promise { + if (cb) { + return super.end(packetOrCb as Packet, cb); + } + + if (typeof packetOrCb === 'function') { + return super.end(packetOrCb); + } + + return new Promise((resolve) => { + if (packetOrCb) { + super.end(packetOrCb, resolve); + return; + } + + super.end(resolve); + }); } /** Returns the local address of the socket. */ @@ -86,19 +146,54 @@ export class PacketSocket extends Duplex { return Boolean(this.socket?.connecting); } + /** Returns whether the socket is connected. */ + get connected(): boolean { + return !this.socket?.pending && this.socket?.readyState === 'open'; + } + + /** Returns an string representation of the socket addresses. */ + override toString(): string { + const remoteAddress = `${this.remoteAddress ?? 'unknown'}:${this.remotePort ?? 0}`; + const localAddress = `${this.localAddress ?? 'unknown'}:${this.localPort ?? 0}`; + + return `${remoteAddress}::${localAddress}`; + } + private wrapSocket(socket: Socket): void { - this.socket = socket; - this.socket.on('close', (hadError) => this.emit('close', hadError)); - this.socket.on('connect', () => this.emit('connect')); - this.socket.on('end', () => this.emit('end')); - this.socket.on('error', (err) => this.emit('error', err)); - this.socket.on('lookup', (err, address, family, host) => this.emit('lookup', err, address, family, host)); // prettier-ignore - this.socket.on('ready', () => this.emit('ready')); - this.socket.on('readable', () => setImmediate(this.onReadable.bind(this))); + const onConnect: PacketSocketEvents['connect'] = () => this.emit('connect'); + const onEnd: PacketSocketEvents['end'] = () => this.emit('end'); + const onError: PacketSocketEvents['error'] = (err) => this.emit('error', err); + const onLookup: PacketSocketEvents['lookup'] = (err, address, family, host) => + this.emit('lookup', err, address, family, host); + const onReady: PacketSocketEvents['ready'] = () => this.emit('ready'); + const onReadable: PacketSocketEvents['readable'] = () => setImmediate(this.onReadable.bind(this)); /* istanbul ignore next - unable to test */ - this.socket.on('drain', () => this.emit('drain')); + const onDrain: PacketSocketEvents['drain'] = () => this.emit('drain'); /* istanbul ignore next - unable to test */ - this.socket.on('timeout', () => this.emit('timeout')); + const onTimeout: PacketSocketEvents['timeout'] = () => this.emit('timeout'); + const onClose: PacketSocketEvents['close'] = (hadError) => { + this.socket?.off('connect', onConnect); + this.socket?.off('end', onEnd); + this.socket?.off('error', onError); + this.socket?.off('lookup', onLookup); + this.socket?.off('ready', onReady); + this.socket?.off('readable', onReadable); + this.socket?.off('drain', onDrain); + this.socket?.off('timeout', onTimeout); + this.socket = undefined; + this.emit('close', hadError); + }; + + this.socket = socket; + this.socket.on('connect', onConnect); + this.socket.on('end', onEnd); + this.socket.on('error', onError); + this.socket.on('lookup', onLookup); + this.socket.on('ready', onReady); + this.socket.on('readable', onReadable); + this.socket.on('drain', onDrain); + this.socket.on('timeout', onTimeout); + this.socket.once('close', onClose); } private onReadable(): void { @@ -137,6 +232,8 @@ export class PacketSocket extends Duplex { return; } + this.debug('received packet', packet.toString()); + const pushOk = this.push(packet); /* istanbul ignore if - unable to test */ @@ -151,7 +248,7 @@ export class PacketSocket extends Duplex { setImmediate(this.onReadable.bind(this)); } - override _write(packet: Packet, _: BufferEncoding, done: Callback): void { + override _write(packet: Packet, _: BufferEncoding, done: InternalCallback): void { if (!this.socket) { done(new DomainException('Socket is not connected')); return; @@ -159,27 +256,35 @@ export class PacketSocket extends Duplex { const buffer = this.packetMapper.fromDomain(packet); + this.debug('sending packet', packet.toString()); this.socket.write(buffer, done); } - override _final(done: Callback): void { + override _final(done: InternalCallback): void { if (!this.socket) { done(new DomainException('Socket is not connected')); return; } + this.debug('closing socket'); this.socket.end(done); } } -export type Callback = (error?: Error | null) => void; +export type InternalCallback = (error?: Error | null) => void; +export type WriteCallback = (error: Error | null | undefined) => void; +export type EndCallback = () => void; export declare interface PacketSocket extends Duplex { emit(event: U, ...args: Parameters): boolean; on(event: U, listener: PacketSocketEvents[U]): this; once(event: U, listener: PacketSocketEvents[U]): this; - write(packet: Packet, encoding?: BufferEncoding, cb?: (error: Error | null | undefined) => void): boolean; - write(packet: Packet, cb?: (error: Error | null | undefined) => void): boolean; - end(cb?: () => void): this; - end(packet: Packet, cb?: () => void): this; + off(event: U, listener: PacketSocketEvents[U]): this; + write(packet: Packet, encoding: BufferEncoding, cb: WriteCallback): boolean; + write(packet: Packet, cb: WriteCallback): boolean; + write(packet: Packet): Promise; + end(packet: Packet, cb: EndCallback): this; + end(packet: Packet): Promise; + end(cb: EndCallback): this; + end(): Promise; } diff --git a/packages/transport-tcp/src/services/payload-object-parser.service.test.ts b/packages/transport-tcp/src/services/payload-data-parser.service.test.ts similarity index 82% rename from packages/transport-tcp/src/services/payload-object-parser.service.test.ts rename to packages/transport-tcp/src/services/payload-data-parser.service.test.ts index 42b7cee9..c7f821f0 100644 --- a/packages/transport-tcp/src/services/payload-object-parser.service.test.ts +++ b/packages/transport-tcp/src/services/payload-data-parser.service.test.ts @@ -1,19 +1,19 @@ import { ArgumentInvalidException } from '@agnoc/toolkit'; import { anything, fnmock, imock, instance, verify, when } from '@johanblumenberg/ts-mockito'; import { expect } from 'chai'; -import { PayloadObjectParserService } from './payload-object-parser.service'; -import type { Decoder, DecoderMap, Encoder, EncoderMap } from './payload-object-parser.service'; -import type { PayloadObject } from '../constants/payloads.constant'; +import { PayloadDataParserService } from './payload-data-parser.service'; +import type { Decoder, DecoderMap, Encoder, EncoderMap } from './payload-data-parser.service'; +import type { PayloadData } from '../constants/payloads.constant'; import type { MapInfo } from '../decoders/map.interface'; import type { Root, Type, Message, Writer } from 'protobufjs/light'; -describe('PayloadObjectParserService', function () { - let service: PayloadObjectParserService; +describe('PayloadDataParserService', function () { + let service: PayloadDataParserService; let protoRoot: Root; let protoType: Type; let protoMessage: Message; let protoWriter: Writer; - let payloadObject: PayloadObject; + let payloadData: PayloadData; let buffer: Buffer; let customDecoders: Partial; let customDecoder: Decoder; @@ -25,26 +25,26 @@ describe('PayloadObjectParserService', function () { protoType = imock(); protoMessage = imock(); protoWriter = imock(); - payloadObject = instance(imock()); + payloadData = instance(imock()); buffer = Buffer.from('example'); customDecoder = fnmock(); customDecoders = { DEVICE_MAPID_GET_GLOBAL_INFO_RSP: instance(customDecoder) }; customEncoder = fnmock(); customEncoders = { DEVICE_MAPID_GET_GLOBAL_INFO_RSP: instance(customEncoder) }; - service = new PayloadObjectParserService(instance(protoRoot), customDecoders, customEncoders); + service = new PayloadDataParserService(instance(protoRoot), customDecoders, customEncoders); }); describe('#getDecoder()', function () { it('should return a decoder function from the protobuf schema', function () { when(protoRoot.get(anything())).thenReturn(instance(protoType)); when(protoType.decode(anything())).thenReturn(instance(protoMessage)); - when(protoType.toObject(anything())).thenReturn(payloadObject); + when(protoType.toObject(anything())).thenReturn(payloadData); when(customDecoder(anything())).thenReturn({}); const fn = service.getDecoder('CLIENT_HEARTBEAT_REQ'); const ret = fn?.(buffer); - expect(ret).to.equal(payloadObject); + expect(ret).to.equal(payloadData); verify(protoRoot.get('CLIENT_HEARTBEAT_REQ')).once(); verify(protoType.decode(buffer)).once(); @@ -55,12 +55,12 @@ describe('PayloadObjectParserService', function () { when(protoRoot.get(anything())).thenReturn(null); when(protoType.decode(anything())).thenReturn(instance(protoMessage)); when(protoType.toObject(anything())).thenReturn({}); - when(customDecoder(anything())).thenReturn(payloadObject); + when(customDecoder(anything())).thenReturn(payloadData); const fn = service.getDecoder('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'); const ret = fn?.(buffer); - expect(ret).to.equal(payloadObject); + expect(ret).to.equal(payloadData); verify(protoRoot.get('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); verify(protoType.decode(anything())).never(); @@ -78,14 +78,14 @@ describe('PayloadObjectParserService', function () { when(customEncoder(anything())).thenReturn(Buffer.alloc(0)); const fn = service.getEncoder('CLIENT_HEARTBEAT_REQ'); - const ret = fn?.(payloadObject); + const ret = fn?.(payloadData); expect(ret).to.exist; expect(Buffer.compare(ret as Buffer, buffer)).to.equal(0); verify(protoRoot.get('CLIENT_HEARTBEAT_REQ')).once(); - verify(protoType.verify(payloadObject)).once(); - verify(protoType.create(payloadObject)).once(); + verify(protoType.verify(payloadData)).once(); + verify(protoType.create(payloadData)).once(); verify(customEncoder(anything())).never(); }); @@ -98,13 +98,13 @@ describe('PayloadObjectParserService', function () { when(customEncoder(anything())).thenReturn(Buffer.alloc(0)); const fn = service.getEncoder('CLIENT_HEARTBEAT_REQ'); - expect(() => fn?.(payloadObject)).to.throw( + expect(() => fn?.(payloadData)).to.throw( ArgumentInvalidException, `Cannot encode a payload for opcode 'CLIENT_HEARTBEAT_REQ' for object 'null': Verify error`, ); verify(protoRoot.get('CLIENT_HEARTBEAT_REQ')).once(); - verify(protoType.verify(payloadObject)).once(); + verify(protoType.verify(payloadData)).once(); verify(protoType.create(anything())).never(); verify(customEncoder(anything())).never(); }); @@ -118,7 +118,7 @@ describe('PayloadObjectParserService', function () { when(customEncoder(anything())).thenReturn(buffer); const fn = service.getEncoder('DEVICE_MAPID_GET_GLOBAL_INFO_RSP'); - const ret = fn?.(payloadObject as MapInfo); + const ret = fn?.(payloadData as MapInfo); expect(ret).to.exist; expect(Buffer.compare(ret as Buffer, buffer)).to.equal(0); @@ -126,7 +126,7 @@ describe('PayloadObjectParserService', function () { verify(protoRoot.get('DEVICE_MAPID_GET_GLOBAL_INFO_RSP')).once(); verify(protoType.verify(anything())).never(); verify(protoType.create(anything())).never(); - verify(customEncoder(payloadObject)).once(); + verify(customEncoder(payloadData)).once(); }); }); }); diff --git a/packages/transport-tcp/src/services/payload-object-parser.service.ts b/packages/transport-tcp/src/services/payload-data-parser.service.ts similarity index 63% rename from packages/transport-tcp/src/services/payload-object-parser.service.ts rename to packages/transport-tcp/src/services/payload-data-parser.service.ts index 06014243..baab9788 100644 --- a/packages/transport-tcp/src/services/payload-object-parser.service.ts +++ b/packages/transport-tcp/src/services/payload-data-parser.service.ts @@ -1,15 +1,15 @@ import { ArgumentInvalidException } from '@agnoc/toolkit'; -import type { PayloadObjectFrom, PayloadObjectName } from '../constants/payloads.constant'; +import type { PayloadDataFrom, PayloadDataName } from '../constants/payloads.constant'; import type { Root, Type } from 'protobufjs/light'; -export type Decoder = (buffer: Buffer) => PayloadObjectFrom; -export type Encoder = (object: PayloadObjectFrom) => Buffer; +export type Decoder = (buffer: Buffer) => PayloadDataFrom; +export type Encoder = (object: PayloadDataFrom) => Buffer; -export type EncoderMap = Record>; -export type DecoderMap = Record>; +export type EncoderMap = Record>; +export type DecoderMap = Record>; /** Service to encde and decode payload objects. */ -export class PayloadObjectParserService { +export class PayloadDataParserService { constructor( private readonly protoRoot: Root, private readonly decoders?: Partial, @@ -17,7 +17,7 @@ export class PayloadObjectParserService { ) {} /** Returns a decoder for a payload object. */ - getDecoder(name: Name): Decoder | undefined { + getDecoder(name: Name): Decoder | undefined { const schema = this.protoRoot.get(name) as Type | null; if (schema) { @@ -28,7 +28,7 @@ export class PayloadObjectParserService { } /** Returns an encoder for a payload object. */ - getEncoder(name: Name): Encoder | undefined { + getEncoder(name: Name): Encoder | undefined { const schema = this.protoRoot.get(name) as Type | null; if (schema) { @@ -38,7 +38,7 @@ export class PayloadObjectParserService { return this.encoders?.[name] as Encoder | undefined; } - private buildSchemaDecoder(name: PayloadObjectName, schema: Type): Decoder { + private buildSchemaDecoder(name: PayloadDataName, schema: Type): Decoder { const decoder = (buffer: Buffer) => { const message = schema.decode(buffer); @@ -50,7 +50,7 @@ export class PayloadObjectParserService { return decoder as Decoder; } - private buildSchemaEncoder(name: PayloadObjectName, schema: Type): Encoder { + private buildSchemaEncoder(name: PayloadDataName, schema: Type): Encoder { const encoder = (object: Buffer) => { const err = schema.verify(object); diff --git a/packages/transport-tcp/src/test-support.ts b/packages/transport-tcp/src/test-support.ts index 77f42c78..208dd7c0 100644 --- a/packages/transport-tcp/src/test-support.ts +++ b/packages/transport-tcp/src/test-support.ts @@ -8,7 +8,7 @@ import type { PayloadProps } from './value-objects/payload.value-object'; export function givenSomePayloadProps(): PayloadProps<'DEVICE_GETTIME_RSP'> { return { opcode: OPCode.fromName('DEVICE_GETTIME_RSP'), - object: { result: 0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: 0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }; } diff --git a/packages/transport-tcp/src/utils/get-custom-decoders.util.ts b/packages/transport-tcp/src/utils/get-custom-decoders.util.ts index d1d2837f..523f92a6 100644 --- a/packages/transport-tcp/src/utils/get-custom-decoders.util.ts +++ b/packages/transport-tcp/src/utils/get-custom-decoders.util.ts @@ -2,7 +2,7 @@ import { decodeArea } from '../decoders/area.decoder'; import { decodeChargePosition } from '../decoders/charge-position.decoder'; import { decodeMap } from '../decoders/map.decoder'; import { decodeRobotPosition } from '../decoders/robot-position.decoder'; -import type { DecoderMap } from '../services/payload-object-parser.service'; +import type { DecoderMap } from '../services/payload-data-parser.service'; const decoders: Partial = { DEVICE_MAPID_GET_GLOBAL_INFO_RSP: decodeMap, diff --git a/packages/transport-tcp/src/value-objects/packet.value-object.test.ts b/packages/transport-tcp/src/value-objects/packet.value-object.test.ts index cd232b82..e8626702 100644 --- a/packages/transport-tcp/src/value-objects/packet.value-object.test.ts +++ b/packages/transport-tcp/src/value-objects/packet.value-object.test.ts @@ -126,12 +126,12 @@ describe('Packet', function () { sequence: new PacketSequence(5n), payload: new Payload({ opcode: OPCode.fromName('DEVICE_GETTIME_RSP'), - object: { result: 0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, + data: { result: 0, body: { deviceTime: 1606129555, deviceTimezone: 3600 } }, }), }); expect(packet.toString()).to.be.equal( - '[5] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","object":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}', + '[5] [ctype: 2] [flow: 1] [userId: 4] [deviceId: 3] {"opcode":"DEVICE_GETTIME_RSP","data":{"result":0,"body":{"deviceTime":1606129555,"deviceTimezone":3600}}}', ); }); }); diff --git a/packages/transport-tcp/src/value-objects/packet.value-object.ts b/packages/transport-tcp/src/value-objects/packet.value-object.ts index af0eb03f..37f3eb03 100644 --- a/packages/transport-tcp/src/value-objects/packet.value-object.ts +++ b/packages/transport-tcp/src/value-objects/packet.value-object.ts @@ -1,10 +1,10 @@ import { ValueObject, ID } from '@agnoc/toolkit'; import { PacketSequence } from '../domain-primitives/packet-sequence.domain-primitive'; import { Payload } from './payload.value-object'; -import type { PayloadObjectName } from '../constants/payloads.constant'; +import type { PayloadDataName } from '../constants/payloads.constant'; /** Describes the properties of a packet. */ -export interface PacketProps { +export interface PacketProps { /** The packet type. */ ctype: number; /** The packet flow. */ @@ -20,7 +20,7 @@ export interface PacketProps { } /** Describes a packet. */ -export class Packet extends ValueObject> { +export class Packet extends ValueObject> { /** Returns the packet type. */ get ctype(): number { return this.props.ctype; diff --git a/packages/transport-tcp/src/value-objects/payload.value-object.test.ts b/packages/transport-tcp/src/value-objects/payload.value-object.test.ts index 1c72c48e..4bce65a0 100644 --- a/packages/transport-tcp/src/value-objects/payload.value-object.test.ts +++ b/packages/transport-tcp/src/value-objects/payload.value-object.test.ts @@ -11,7 +11,7 @@ describe('Payload', function () { expect(payload).to.be.instanceOf(ValueObject); expect(payload.opcode).to.be.equal(payloadProps.opcode); - expect(payload.object).to.be.equal(payloadProps.object); + expect(payload.data).to.be.equal(payloadProps.data); }); it("should throw an error when 'opcode' is not provided", function () { @@ -24,9 +24,9 @@ describe('Payload', function () { it("should throw an error when 'object' is not provided", function () { // @ts-expect-error - missing property - expect(() => new Payload({ ...givenSomePayloadProps(), object: undefined })).to.throw( + expect(() => new Payload({ ...givenSomePayloadProps(), data: undefined })).to.throw( ArgumentNotProvidedException, - `Property 'object' for Payload not provided`, + `Property 'data' for Payload not provided`, ); }); @@ -38,11 +38,11 @@ describe('Payload', function () { ); }); - it('should throw an error when "object" is not an object', function () { + it('should throw an error when "data" is not an object', function () { // @ts-expect-error - invalid property - expect(() => new Payload({ ...givenSomePayloadProps(), object: 'foo' })).to.throw( + expect(() => new Payload({ ...givenSomePayloadProps(), data: 'foo' })).to.throw( ArgumentInvalidException, - `Value 'foo' for property 'object' of Payload is not an instance of Object`, + `Value 'foo' for property 'data' of Payload is not an instance of Object`, ); }); @@ -50,26 +50,35 @@ describe('Payload', function () { it('should return a string representation of the Payload', function () { const payload = new Payload<'DEVICE_MAPID_PUSH_MAP_INFO'>({ opcode: OPCode.fromName('DEVICE_MAPID_PUSH_MAP_INFO'), - object: { mask: 0, mapGrid: Buffer.from('example') }, + data: { + mask: 0, + mapGrid: Buffer.from('example'), + historyHeadInfo: { + mapHeadId: 1, + poseId: 1, + pointList: new Array(20).fill(0).map((_, i) => ({ flag: i, x: i, y: i })), + pointNumber: 0, + }, + }, }); expect(payload.toString()).to.be.equal( - '{"opcode":"DEVICE_MAPID_PUSH_MAP_INFO","object":{"mask":0,"mapGrid":"[Buffer]"}}', + '{"opcode":"DEVICE_MAPID_PUSH_MAP_INFO","data":{"mask":0,"mapGrid":"[Buffer]","historyHeadInfo":{"mapHeadId":1,"poseId":1,"pointList":[{"flag":0,"x":0,"y":0},{"flag":1,"x":1,"y":1},{"flag":2,"x":2,"y":2},{"flag":3,"x":3,"y":3},{"flag":4,"x":4,"y":4},{"flag":5,"x":5,"y":5},{"flag":6,"x":6,"y":6},{"flag":7,"x":7,"y":7},{"flag":8,"x":8,"y":8},{"flag":9,"x":9,"y":9},"[20 more items...]"],"pointNumber":0}}}', ); }); }); describe('#toJSON()', function () { it('should return a JSON representation of the Payload', function () { - const object = { mask: 0, mapGrid: Buffer.from('example') }; + const data = { mask: 0, mapGrid: Buffer.from('example') }; const payload = new Payload<'DEVICE_MAPID_PUSH_MAP_INFO'>({ opcode: OPCode.fromName('DEVICE_MAPID_PUSH_MAP_INFO'), - object, + data, }); expect(payload.toJSON()).to.be.deep.equal({ opcode: 'DEVICE_MAPID_PUSH_MAP_INFO', - object, + data, }); }); }); diff --git a/packages/transport-tcp/src/value-objects/payload.value-object.ts b/packages/transport-tcp/src/value-objects/payload.value-object.ts index 413ef350..8bed152b 100644 --- a/packages/transport-tcp/src/value-objects/payload.value-object.ts +++ b/packages/transport-tcp/src/value-objects/payload.value-object.ts @@ -1,33 +1,33 @@ import { isObject, ValueObject } from '@agnoc/toolkit'; import { OPCode } from '../domain-primitives/opcode.domain-primitive'; -import type { PayloadObjectFrom, PayloadObjectName } from '../constants/payloads.constant'; +import type { PayloadDataFrom, PayloadDataName } from '../constants/payloads.constant'; /** Describes the properties of a payload. */ -export interface PayloadProps { +export interface PayloadProps { /** The opcode of the payload. */ opcode: OPCode; /** The object representation of the payload. */ - object: PayloadObjectFrom; + data: PayloadDataFrom; } /** Describes the JSON representation of a payload. */ -export interface JSONPayload { +export interface JSONPayload { /** The name of the opcode for the payload. */ opcode: Name; /** The object representation of the payload. */ - object: PayloadObjectFrom; + data: PayloadDataFrom; } /** Describes a payload. */ -export class Payload extends ValueObject> { +export class Payload extends ValueObject> { /** Returns the opcode of the payload. */ get opcode(): OPCode { return this.props.opcode; } /** Returns the object representation of the payload. */ - get object(): PayloadObjectFrom { - return this.props.object; + get data(): PayloadDataFrom { + return this.props.data; } /** Returns the string representation of the payload with some properties filtered. */ @@ -37,18 +37,18 @@ export class Payload extends /** Returns the JSON representation of the payload. */ override toJSON(): JSONPayload { - return { opcode: this.props.opcode.name as Name, object: this.props.object }; + return { opcode: this.props.opcode.name as Name, data: this.props.data }; } protected validate(props: PayloadProps): void { - const keys: (keyof PayloadProps)[] = ['opcode', 'object']; + const keys: (keyof PayloadProps)[] = ['opcode', 'data']; keys.forEach((prop) => { this.validateDefinedProp(props, prop); }); this.validateInstanceProp(props, 'opcode', OPCode); - this.validateInstanceProp(props, 'object', Object); + this.validateInstanceProp(props, 'data', Object); } } @@ -57,5 +57,9 @@ function filterProperties(_: string, value: unknown) { return '[Buffer]'; } + if (Array.isArray(value) && value.length > 10) { + return value.slice(0, 10).concat(`[${value.length} more items...]`) as unknown[]; + } + return value; } diff --git a/tsconfig.json b/tsconfig.json index 16834190..6a6b5e91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,13 @@ "include": ["packages/*/src", "packages/*/test", "packages/*/types"], "typedocOptions": { "entryPoints": [ + "packages/core", "packages/cli", "packages/adapter-tcp", "packages/transport-tcp", "packages/domain", - "packages/toolkit" + "packages/toolkit", + "packages/test-support" ], "entryPointStrategy": "packages", "out": "public/typedoc", @@ -18,5 +20,8 @@ "includeVersion": true, "sort": ["kind", "instance-first", "static-first"], "hideGenerator": true + }, + "ts-node": { + "files": true } } diff --git a/yarn.lock b/yarn.lock index f85856e9..572b1fa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -191,14 +191,19 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" -"@commitlint/cli@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/cli/-/cli-17.4.4.tgz#36df08bfa31dbb9a2b6b1d7187a31e578f001a06" - integrity sha512-HwKlD7CPVMVGTAeFZylVNy14Vm5POVY0WxPkZr7EXLC/os0LH/obs6z4HRvJtH/nHCMYBvUBQhGwnufKfTjd5g== +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + +"@commitlint/cli@^17.5.0": + version "17.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/cli/-/cli-17.5.0.tgz#045bf46fc38bb4246da30b462d8db66f48c28f9a" + integrity sha512-yNW3+M7UM1ioK28LKTrryIVB5qGpXlEv8+rJQiWPMZNayy9/1XR5+lL8qBTNlgopYtZWWnIm5RETcAN29ZTL/A== dependencies: "@commitlint/format" "^17.4.4" "@commitlint/lint" "^17.4.4" - "@commitlint/load" "^17.4.4" + "@commitlint/load" "^17.5.0" "@commitlint/read" "^17.4.4" "@commitlint/types" "^17.4.4" execa "^5.0.0" @@ -265,10 +270,10 @@ "@commitlint/rules" "^17.4.4" "@commitlint/types" "^17.4.4" -"@commitlint/load@>6.1.1", "@commitlint/load@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-17.4.4.tgz#13fcb553572f265339801cde6dd10ee5eea07f5e" - integrity sha512-z6uFIQ7wfKX5FGBe1AkOF4l/ShOQsaa1ml/nLMkbW7R/xF8galGS7Zh0yHvzVp/srtfS0brC+0bUfQfmpMPFVQ== +"@commitlint/load@>6.1.1", "@commitlint/load@^17.5.0": + version "17.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-17.5.0.tgz#be45dbbb50aaf5eb7e8e940e1e0d6171d1426bab" + integrity sha512-l+4W8Sx4CD5rYFsrhHH8HP01/8jEP7kKf33Xlx2Uk2out/UKoKPYMOIRcDH5ppT8UXLMV+x6Wm5osdRKKgaD1Q== dependencies: "@commitlint/config-validator" "^17.4.4" "@commitlint/execute-rule" "^17.4.0" @@ -283,7 +288,7 @@ lodash.uniq "^4.5.0" resolve-from "^5.0.0" ts-node "^10.8.1" - typescript "^4.6.4" + typescript "^4.6.4 || ^5.0.0" "@commitlint/message@^17.4.2": version "17.4.2" @@ -359,14 +364,26 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@eslint/eslintrc@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.0.tgz#943309d8697c52fc82c076e90c1c74fbbe69dbff" - integrity sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A== +"@eslint-community/eslint-utils@^4.2.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.3.0.tgz#a556790523a351b4e47e9d385f47265eaaf9780a" + integrity sha512-v3oplH6FYCULtFuCeqyuTd9D2WKO937Dxdq+GmHOLL72TTRriLxz2VLlNfkZRsvj6PKnOPAtuT6dwrs/pA5DvA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.4.0": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.4.1.tgz#087cb8d9d757bb22e9c9946c9c0c2bf8806830f1" + integrity sha512-BISJ6ZE4xQsuL/FmsyRaiffpq977bMlsKfGHTQrOGFErfByxIe6iZTxPf/00Zon9b9a7iUykfQwejN3s2ZW/Bw== + +"@eslint/eslintrc@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.1.tgz#7888fe7ec8f21bc26d646dbd2c11cd776e21192d" + integrity sha512-eFRmABvW2E5Ho6f5fHLqgena46rOj7r7OKHYfLElqcBfGFHHpjBhivyi5+jOEQuSpdc/1phIZJlbC2te+tZNIw== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.4.0" + espree "^9.5.0" globals "^13.19.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -374,16 +391,21 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.35.0": - version "8.35.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.35.0.tgz#b7569632b0b788a0ca0e438235154e45d42813a7" - integrity sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw== +"@eslint/js@8.36.0": + version "8.36.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.36.0.tgz#9837f768c03a1e4a30bd304a64fb8844f0e72efe" + integrity sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg== "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@guardian/eslint-plugin-tsdoc-required@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@guardian/eslint-plugin-tsdoc-required/-/eslint-plugin-tsdoc-required-0.1.3.tgz#1c5517753db676cd8119c0fc0c50eab3b0ff6e77" + integrity sha512-ohk4/a0Kq9oEq4sSBFS+0XU3AjANwcEbK5HgBHNyIBZgc+7SU0ws+3/7W9QrBZts1lXkOhqBrIW+D+PG5Rk39A== + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -436,6 +458,13 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@jest/schemas@^29.4.3": + version "29.4.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788" + integrity sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg== + dependencies: + "@sinclair/typebox" "^0.25.16" + "@johanblumenberg/ts-mockito@^1.0.35": version "1.0.35" resolved "https://registry.yarnpkg.com/@johanblumenberg/ts-mockito/-/ts-mockito-1.0.35.tgz#045fde7d479c2d0ed0156ea8e5ffb6eddbbd14b8" @@ -498,21 +527,21 @@ dependencies: lodash "^4.17.21" -"@lerna/child-process@6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@lerna/child-process/-/child-process-6.5.1.tgz#da9161ba00e8d67fa7241a709703e5cc5e4a5e5e" - integrity sha512-QfyleXSD9slh4qM54wDaqKVPvtUH1NJMgsFc9BabqSHO1Ttpandv1EAvTCN9Lu73RbCX3LJpn+BfJmnjHbjCyw== +"@lerna/child-process@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@lerna/child-process/-/child-process-6.6.0.tgz#6bd2effce7a2dd3a06b67db1c2cb6cc999859519" + integrity sha512-Y1elVHYKBLvdseKu74ud+pWqQi0I81AeWBKLRs0ehnD2EZsmy/f5ILkRcNU9aUp4GPF1TMUPAq2/+0By97gb1g== dependencies: chalk "^4.1.0" execa "^5.0.0" strong-log-transformer "^2.1.0" -"@lerna/create@6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@lerna/create/-/create-6.5.1.tgz#326b5d26c247bfc9e2d8728aa1f69419840cec8c" - integrity sha512-ejERJnfA36jEuKrfM+94feLiyf2/hF2NoG923N0rE4rsmvRFPr1XLVPvAKleXW+Gdi/t1p410lJ7NKaLRMYCYw== +"@lerna/create@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@lerna/create/-/create-6.6.0.tgz#7cec9d9c16c00e7d6359e76e75a12d2dc3ac987b" + integrity sha512-F1Q4bB9QzePSnIPd2ZY6a+3l/aZiYYmN1J0ednr8O+xMOyZsumX29c4XvnS7rgovz4aFqi+XdXiL35vmvrSfjA== dependencies: - "@lerna/child-process" "6.5.1" + "@lerna/child-process" "6.6.0" dedent "^0.7.0" fs-extra "^9.1.0" init-package-json "^3.0.2" @@ -526,6 +555,75 @@ validate-npm-package-name "^4.0.0" yargs-parser "20.2.4" +"@lerna/legacy-package-management@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@lerna/legacy-package-management/-/legacy-package-management-6.6.0.tgz#099a918cf1df82aa47ae21172db25643c5b0e4f5" + integrity sha512-vGkQv7nuZNhjs1+Arm1m+CKyBblig4oSv/PakyNnqTnRNryIgdXqYFn8DGGkUsA3qXLlouY41Sz5xEY/7hjNEA== + dependencies: + "@npmcli/arborist" "6.2.3" + "@npmcli/run-script" "4.1.7" + "@nrwl/devkit" ">=15.5.2 < 16" + "@octokit/rest" "19.0.3" + byte-size "7.0.0" + chalk "4.1.0" + clone-deep "4.0.1" + cmd-shim "5.0.0" + columnify "1.6.0" + config-chain "1.1.12" + conventional-changelog-core "4.2.4" + conventional-recommended-bump "6.1.0" + cosmiconfig "7.0.0" + dedent "0.7.0" + dot-prop "6.0.1" + execa "5.0.0" + file-url "3.0.0" + find-up "5.0.0" + fs-extra "9.1.0" + get-port "5.1.1" + get-stream "6.0.0" + git-url-parse "13.1.0" + glob-parent "5.1.2" + globby "11.1.0" + graceful-fs "4.2.10" + has-unicode "2.0.1" + inquirer "8.2.4" + is-ci "2.0.0" + is-stream "2.0.0" + libnpmpublish "6.0.4" + load-json-file "6.2.0" + make-dir "3.1.0" + minimatch "3.0.5" + multimatch "5.0.0" + node-fetch "2.6.7" + npm-package-arg "8.1.1" + npm-packlist "5.1.1" + npm-registry-fetch "14.0.3" + npmlog "6.0.2" + p-map "4.0.0" + p-map-series "2.1.0" + p-queue "6.6.2" + p-waterfall "2.1.1" + pacote "13.6.2" + path-exists "4.0.0" + pify "5.0.0" + pretty-format "29.4.3" + read-cmd-shim "3.0.0" + read-package-json "5.0.1" + resolve-from "5.0.0" + semver "7.3.8" + signal-exit "3.0.7" + slash "3.0.0" + ssri "9.0.1" + strong-log-transformer "2.1.0" + tar "6.1.11" + temp-dir "1.0.0" + tempy "1.0.0" + upath "2.0.1" + uuid "8.3.2" + write-file-atomic "4.0.1" + write-pkg "4.0.0" + yargs "16.2.0" + "@microsoft/tsdoc-config@0.16.2": version "0.16.2" resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz#b786bb4ead00d54f53839a458ce626c8548d3adf" @@ -562,44 +660,43 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@npmcli/arborist@5.3.0": - version "5.3.0" - resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-5.3.0.tgz#321d9424677bfc08569e98a5ac445ee781f32053" - integrity sha512-+rZ9zgL1lnbl8Xbb1NQdMjveOMwj4lIYfcDtyJHHi5x4X8jtR6m8SXooJMZy5vmFVZ8w7A2Bnd/oX9eTuU8w5A== +"@npmcli/arborist@6.2.3": + version "6.2.3" + resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-6.2.3.tgz#31f8aed2588341864d3811151d929c01308f8e71" + integrity sha512-lpGOC2ilSJXcc2zfW9QtukcCTcMbl3fVI0z4wvFB2AFIl0C+Q6Wv7ccrpdrQa8rvJ1ZVuc6qkX7HVTyKlzGqKA== dependencies: "@isaacs/string-locale-compare" "^1.1.0" - "@npmcli/installed-package-contents" "^1.0.7" - "@npmcli/map-workspaces" "^2.0.3" - "@npmcli/metavuln-calculator" "^3.0.1" - "@npmcli/move-file" "^2.0.0" - "@npmcli/name-from-folder" "^1.0.1" - "@npmcli/node-gyp" "^2.0.0" - "@npmcli/package-json" "^2.0.0" - "@npmcli/run-script" "^4.1.3" - bin-links "^3.0.0" - cacache "^16.0.6" + "@npmcli/fs" "^3.1.0" + "@npmcli/installed-package-contents" "^2.0.0" + "@npmcli/map-workspaces" "^3.0.2" + "@npmcli/metavuln-calculator" "^5.0.0" + "@npmcli/name-from-folder" "^2.0.0" + "@npmcli/node-gyp" "^3.0.0" + "@npmcli/package-json" "^3.0.0" + "@npmcli/query" "^3.0.0" + "@npmcli/run-script" "^6.0.0" + bin-links "^4.0.1" + cacache "^17.0.4" common-ancestor-path "^1.0.1" - json-parse-even-better-errors "^2.3.1" + hosted-git-info "^6.1.1" + json-parse-even-better-errors "^3.0.0" json-stringify-nice "^1.1.4" - mkdirp "^1.0.4" - mkdirp-infer-owner "^2.0.0" - nopt "^5.0.0" - npm-install-checks "^5.0.0" - npm-package-arg "^9.0.0" - npm-pick-manifest "^7.0.0" - npm-registry-fetch "^13.0.0" - npmlog "^6.0.2" - pacote "^13.6.1" - parse-conflict-json "^2.0.1" - proc-log "^2.0.0" + minimatch "^6.1.6" + nopt "^7.0.0" + npm-install-checks "^6.0.0" + npm-package-arg "^10.1.0" + npm-pick-manifest "^8.0.1" + npm-registry-fetch "^14.0.3" + npmlog "^7.0.1" + pacote "^15.0.8" + parse-conflict-json "^3.0.0" + proc-log "^3.0.0" promise-all-reject-late "^1.0.0" promise-call-limit "^1.0.1" - read-package-json-fast "^2.0.2" - readdir-scoped-modules "^1.1.0" - rimraf "^3.0.2" + read-package-json-fast "^3.0.2" semver "^7.3.7" - ssri "^9.0.0" - treeverse "^2.0.0" + ssri "^10.0.1" + treeverse "^3.0.0" walk-up-path "^1.0.0" "@npmcli/fs@^2.1.0": @@ -655,32 +752,32 @@ npm-bundled "^1.1.1" npm-normalize-package-bin "^1.0.1" -"@npmcli/installed-package-contents@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-2.0.1.tgz#3cad3141c95613426820128757a3549bef1b346b" - integrity sha512-GIykAFdOVK31Q1/zAtT5MbxqQL2vyl9mvFJv+OGu01zxbhL3p0xc8gJjdNGX1mWmUT43aEKVO2L6V/2j4TOsAA== +"@npmcli/installed-package-contents@^2.0.0", "@npmcli/installed-package-contents@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz#bfd817eccd9e8df200919e73f57f9e3d9e4f9e33" + integrity sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ== dependencies: npm-bundled "^3.0.0" npm-normalize-package-bin "^3.0.0" -"@npmcli/map-workspaces@^2.0.3": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@npmcli/map-workspaces/-/map-workspaces-2.0.4.tgz#9e5e8ab655215a262aefabf139782b894e0504fc" - integrity sha512-bMo0aAfwhVwqoVM5UzX1DJnlvVvzDCHae821jv48L1EsrYwfOZChlqWYXEtto/+BkBXetPbEWgau++/brh4oVg== +"@npmcli/map-workspaces@^3.0.2": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@npmcli/map-workspaces/-/map-workspaces-3.0.3.tgz#476944b63cd1f65bf83c6fdc7f4ca7be56906b1f" + integrity sha512-HlCvFuTzw4UNoKyZdqiNrln+qMF71QJkxy2dsusV8QQdoa89e2TF4dATCzBxbl4zzRzdDoWWyP5ADVrNAH9cRQ== dependencies: - "@npmcli/name-from-folder" "^1.0.1" - glob "^8.0.1" - minimatch "^5.0.1" - read-package-json-fast "^2.0.3" + "@npmcli/name-from-folder" "^2.0.0" + glob "^9.3.1" + minimatch "^7.4.2" + read-package-json-fast "^3.0.0" -"@npmcli/metavuln-calculator@^3.0.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@npmcli/metavuln-calculator/-/metavuln-calculator-3.1.1.tgz#9359bd72b400f8353f6a28a25c8457b562602622" - integrity sha512-n69ygIaqAedecLeVH3KnO39M6ZHiJ2dEv5A7DGvcqCB8q17BGUgW8QaanIkbWUo2aYGZqJaOORTLAlIvKjNDKA== +"@npmcli/metavuln-calculator@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/metavuln-calculator/-/metavuln-calculator-5.0.0.tgz#917c3be49ebed0b424b07f38060b929127e4c499" + integrity sha512-BBFQx4M12wiEuVwCgtX/Depx0B/+NHMwDWOlXT41/Pdy5W/1Fenk+hibUlMSrFWwASbX+fY90UbILAEIYH02/A== dependencies: - cacache "^16.0.0" - json-parse-even-better-errors "^2.3.1" - pacote "^13.0.3" + cacache "^17.0.0" + json-parse-even-better-errors "^3.0.0" + pacote "^15.0.0" semver "^7.3.5" "@npmcli/move-file@^2.0.0": @@ -691,10 +788,10 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@npmcli/name-from-folder@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-1.0.1.tgz#77ecd0a4fcb772ba6fe927e2e2e155fbec2e6b1a" - integrity sha512-qq3oEfcLFwNfEYOQ8HLimRGKlD8WSeGEdtUa7hmzpR8Sa7haL1KVQrvgO6wqMjhWFFVjgtrh1gIxDz+P8sjUaA== +"@npmcli/name-from-folder@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-2.0.0.tgz#c44d3a7c6d5c184bb6036f4d5995eee298945815" + integrity sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg== "@npmcli/node-gyp@^2.0.0": version "2.0.0" @@ -706,12 +803,12 @@ resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz#101b2d0490ef1aa20ed460e4c0813f0db560545a" integrity sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA== -"@npmcli/package-json@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-2.0.0.tgz#3bbcf4677e21055adbe673d9f08c9f9cde942e4a" - integrity sha512-42jnZ6yl16GzjWSH7vtrmWyJDGVa/LXPdpN2rcUWolFjc9ON2N3uz0qdBbQACfmhuJZ2lbKYtmK5qx68ZPLHMA== +"@npmcli/package-json@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-3.0.0.tgz#c9219a197e1be8dbf43c4ef8767a72277c0533b6" + integrity sha512-NnuPuM97xfiCpbTEJYtEuKz6CFbpUHtaT0+5via5pQeI25omvQDFbp1GcGJ/c4zvL/WX0qbde6YiLgfZbWFgvg== dependencies: - json-parse-even-better-errors "^2.3.1" + json-parse-even-better-errors "^3.0.0" "@npmcli/promise-spawn@^3.0.0": version "3.0.0" @@ -727,7 +824,14 @@ dependencies: which "^3.0.0" -"@npmcli/run-script@4.1.7": +"@npmcli/query@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/query/-/query-3.0.0.tgz#51a0dfb85811e04f244171f164b6bc83b36113a7" + integrity sha512-MFNDSJNgsLZIEBVZ0Q9w9K7o07j5N4o4yjtdz2uEpuCZlXGMuPENiRaFYk0vRqAA64qVuUQwC05g27fRtfUgnA== + dependencies: + postcss-selector-parser "^6.0.10" + +"@npmcli/run-script@4.1.7", "@npmcli/run-script@^4.1.0": version "4.1.7" resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-4.1.7.tgz#b1a2f57568eb738e45e9ea3123fb054b400a86f7" integrity sha512-WXr/MyM4tpKA4BotB81NccGAv8B48lNH0gRoILucbcAhTQXLCoi6HflMV3KdXubIqvP9SuLsFn68Z7r4jl+ppw== @@ -738,17 +842,6 @@ read-package-json-fast "^2.0.3" which "^2.0.2" -"@npmcli/run-script@^4.1.0", "@npmcli/run-script@^4.1.3": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-4.2.1.tgz#c07c5c71bc1c70a5f2a06b0d4da976641609b946" - integrity sha512-7dqywvVudPSrRCW5nTHpHgeWnbBtz8cFkOuKrecm6ih+oO9ciydhWt6OF7HlqupRRmB8Q/gECVdB9LMfToJbRg== - dependencies: - "@npmcli/node-gyp" "^2.0.0" - "@npmcli/promise-spawn" "^3.0.0" - node-gyp "^9.0.0" - read-package-json-fast "^2.0.3" - which "^2.0.2" - "@npmcli/run-script@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-6.0.0.tgz#f89e322c729e26ae29db6cc8cc76559074aac208" @@ -842,7 +935,7 @@ debug "^4.1.1" semver "^7.3.8" -"@oclif/config@1.18.6": +"@oclif/config@1.18.6", "@oclif/config@^1.18.2": version "1.18.6" resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.18.6.tgz#37367026b3110a2f04875509b1920a8ee4489f21" integrity sha512-OWhCpdu4QqggOPX1YPZ4XVmLLRX+lhGjXV6RNA7sogOwLqlEmSslnN/lhR5dkhcWZbKWBQH29YCrB3LDPRu/IA== @@ -854,18 +947,6 @@ is-wsl "^2.1.1" tslib "^2.3.1" -"@oclif/config@^1.18.2": - version "1.18.8" - resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.18.8.tgz#efaccbd0381f90a98fa69c9131e14c5a91fc0659" - integrity sha512-FetS52+emaZQui0roFSdbBP8ddBkIezEoH2NcjLJRjqkMGdE9Z1V+jsISVqTYXk2KJ1gAI0CHDXFjJlNBYbJBg== - dependencies: - "@oclif/errors" "^1.3.6" - "@oclif/parser" "^3.8.10" - debug "^4.3.4" - globby "^11.1.0" - is-wsl "^2.1.1" - tslib "^2.5.0" - "@oclif/errors@1.3.6", "@oclif/errors@^1.3.5", "@oclif/errors@^1.3.6": version "1.3.6" resolved "https://registry.yarnpkg.com/@oclif/errors/-/errors-1.3.6.tgz#e8fe1fc12346cb77c4f274e26891964f5175f75d" @@ -1137,6 +1218,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@sinclair/typebox@^0.25.16": + version "0.25.24" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" + integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== + "@sindresorhus/is@^5.2.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.3.0.tgz#0ec9264cf54a527671d990eb874e030b55b70dcc" @@ -1258,10 +1344,10 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== -"@types/node@*", "@types/node@>=13.7.0", "@types/node@^18.14.2": - version "18.14.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1" - integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA== +"@types/node@*", "@types/node@>=13.7.0", "@types/node@^18.15.5": + version "18.15.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.5.tgz#3af577099a99c61479149b716183e70b5239324a" + integrity sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew== "@types/node@^10.0.0": version "10.17.60" @@ -1283,88 +1369,88 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== -"@typescript-eslint/eslint-plugin@^5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.53.0.tgz#24b8b4a952f3c615fe070e3c461dd852b5056734" - integrity sha512-alFpFWNucPLdUOySmXCJpzr6HKC3bu7XooShWM+3w/EL6J2HIoB2PFxpLnq4JauWVk6DiVeNKzQlFEaE+X9sGw== +"@typescript-eslint/eslint-plugin@^5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.56.0.tgz#e4fbb4d6dd8dab3e733485c1a44a02189ae75364" + integrity sha512-ZNW37Ccl3oMZkzxrYDUX4o7cnuPgU+YrcaYXzsRtLB16I1FR5SHMqga3zGsaSliZADCWo2v8qHWqAYIj8nWCCg== dependencies: - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/type-utils" "5.53.0" - "@typescript-eslint/utils" "5.53.0" + "@eslint-community/regexpp" "^4.4.0" + "@typescript-eslint/scope-manager" "5.56.0" + "@typescript-eslint/type-utils" "5.56.0" + "@typescript-eslint/utils" "5.56.0" debug "^4.3.4" grapheme-splitter "^1.0.4" ignore "^5.2.0" natural-compare-lite "^1.4.0" - regexpp "^3.2.0" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.53.0.tgz#a1f2b9ae73b83181098747e96683f1b249ecab52" - integrity sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ== +"@typescript-eslint/parser@^5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.56.0.tgz#42eafb44b639ef1dbd54a3dbe628c446ca753ea6" + integrity sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg== dependencies: - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/typescript-estree" "5.53.0" + "@typescript-eslint/scope-manager" "5.56.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/typescript-estree" "5.56.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.53.0.tgz#42b54f280e33c82939275a42649701024f3fafef" - integrity sha512-Opy3dqNsp/9kBBeCPhkCNR7fmdSQqA+47r21hr9a14Bx0xnkElEQmhoHga+VoaoQ6uDHjDKmQPIYcUcKJifS7w== +"@typescript-eslint/scope-manager@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.56.0.tgz#62b4055088903b5254fa20403010e1c16d6ab725" + integrity sha512-jGYKyt+iBakD0SA5Ww8vFqGpoV2asSjwt60Gl6YcO8ksQ8s2HlUEyHBMSa38bdLopYqGf7EYQMUIGdT/Luw+sw== dependencies: - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/visitor-keys" "5.53.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/visitor-keys" "5.56.0" -"@typescript-eslint/type-utils@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.53.0.tgz#41665449935ba9b4e6a1ba6e2a3f4b2c31d6cf97" - integrity sha512-HO2hh0fmtqNLzTAme/KnND5uFNwbsdYhCZghK2SoxGp3Ifn2emv+hi0PBUjzzSh0dstUIFqOj3bp0AwQlK4OWw== +"@typescript-eslint/type-utils@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.56.0.tgz#e6f004a072f09c42e263dc50e98c70b41a509685" + integrity sha512-8WxgOgJjWRy6m4xg9KoSHPzBNZeQbGlQOH7l2QEhQID/+YseaFxg5J/DLwWSsi9Axj4e/cCiKx7PVzOq38tY4A== dependencies: - "@typescript-eslint/typescript-estree" "5.53.0" - "@typescript-eslint/utils" "5.53.0" + "@typescript-eslint/typescript-estree" "5.56.0" + "@typescript-eslint/utils" "5.56.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.53.0.tgz#f79eca62b97e518ee124086a21a24f3be267026f" - integrity sha512-5kcDL9ZUIP756K6+QOAfPkigJmCPHcLN7Zjdz76lQWWDdzfOhZDTj1irs6gPBKiXx5/6O3L0+AvupAut3z7D2A== +"@typescript-eslint/types@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.56.0.tgz#b03f0bfd6fa2afff4e67c5795930aff398cbd834" + integrity sha512-JyAzbTJcIyhuUhogmiu+t79AkdnqgPUEsxMTMc/dCZczGMJQh1MK2wgrju++yMN6AWroVAy2jxyPcPr3SWCq5w== -"@typescript-eslint/typescript-estree@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.53.0.tgz#bc651dc28cf18ab248ecd18a4c886c744aebd690" - integrity sha512-eKmipH7QyScpHSkhbptBBYh9v8FxtngLquq292YTEQ1pxVs39yFBlLC1xeIZcPPz1RWGqb7YgERJRGkjw8ZV7w== +"@typescript-eslint/typescript-estree@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.56.0.tgz#48342aa2344649a03321e74cab9ccecb9af086c3" + integrity sha512-41CH/GncsLXOJi0jb74SnC7jVPWeVJ0pxQj8bOjH1h2O26jXN3YHKDT1ejkVz5YeTEQPeLCCRY0U2r68tfNOcg== dependencies: - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/visitor-keys" "5.53.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/visitor-keys" "5.56.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.53.0.tgz#e55eaad9d6fffa120575ffaa530c7e802f13bce8" - integrity sha512-VUOOtPv27UNWLxFwQK/8+7kvxVC+hPHNsJjzlJyotlaHjLSIgOCKj9I0DBUjwOOA64qjBwx5afAPjksqOxMO0g== +"@typescript-eslint/utils@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.56.0.tgz#db64705409b9a15546053fb4deb2888b37df1f41" + integrity sha512-XhZDVdLnUJNtbzaJeDSCIYaM+Tgr59gZGbFuELgF7m0IY03PlciidS7UQNKLE0+WpUTn1GlycEr6Ivb/afjbhA== dependencies: + "@eslint-community/eslint-utils" "^4.2.0" "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/typescript-estree" "5.53.0" + "@typescript-eslint/scope-manager" "5.56.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/typescript-estree" "5.56.0" eslint-scope "^5.1.1" - eslint-utils "^3.0.0" semver "^7.3.7" -"@typescript-eslint/visitor-keys@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.53.0.tgz#8a5126623937cdd909c30d8fa72f79fa56cc1a9f" - integrity sha512-JqNLnX3leaHFZEN0gCh81sIvgrp/2GOACZNgO4+Tkf64u51kTpAyWFOY8XHx8XuXr3N2C9zgPPHtcpMg6z1g0w== +"@typescript-eslint/visitor-keys@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.56.0.tgz#f19eb297d972417eb13cb69b35b3213e13cc214f" + integrity sha512-1mFdED7u5bZpX6Xxf5N9U2c18sb+8EvU3tyOIj6LQZ5OOvnmj8BVeNNP603OFPm5KkS1a7IvCIcwrdHXaEMG/Q== dependencies: - "@typescript-eslint/types" "5.53.0" + "@typescript-eslint/types" "5.56.0" eslint-visitor-keys "^3.3.0" "@yarnpkg/lockfile@^1.1.0": @@ -1395,11 +1481,23 @@ JSONStream@^1.0.4: jsonparse "^1.2.0" through ">=2.2.7 <3" -abbrev@1, abbrev@^1.0.0: +abbrev@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1471,16 +1569,11 @@ ansi-align@^3.0.1: dependencies: string-width "^4.1.0" -ansi-colors@4.1.1: +ansi-colors@4.1.1, ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== -ansi-colors@^4.1.1: - version "4.1.3" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" - integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== - ansi-escapes@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" @@ -1522,6 +1615,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0, ansi-styles@^4.2.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.0.0, ansi-styles@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" @@ -1565,6 +1663,14 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +are-we-there-yet@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-4.0.0.tgz#3ff397dc14f08b52dd8b2a64d3cee154ab8760d2" + integrity sha512-nSXlV+u3vtVjRgihdTzbfWYzxPWGo424zPgQbHD0ZqIla3jqYAewDcvee0Ua2hjS5IfTAmjGlx1Jf0PKwjZDEw== + dependencies: + delegates "^1.0.0" + readable-stream "^4.1.0" + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -1648,11 +1754,6 @@ arrify@^2.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== -asap@^2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -1707,17 +1808,15 @@ before-after-hook@^2.2.0: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== -bin-links@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-3.0.3.tgz#3842711ef3db2cd9f16a5f404a996a12db355a6e" - integrity sha512-zKdnMPWEdh4F5INR07/eBrodC7QrF5JKvqskjz/ZZRXg5YSAZIbn8zGhbhUrElzHBZ2fvEQdOU59RHcTG3GiwA== +bin-links@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-4.0.1.tgz#afeb0549e642f61ff889b58ea2f8dca78fb9d8d3" + integrity sha512-bmFEM39CyX336ZGGRsGPlc6jZHriIoHacOQcTt72MktIjpPhZoP4te2jOyUXF3BLILmJ8aNLncoPVeIIFlrDeA== dependencies: - cmd-shim "^5.0.0" - mkdirp-infer-owner "^2.0.0" - npm-normalize-package-bin "^2.0.0" - read-cmd-shim "^3.0.0" - rimraf "^3.0.0" - write-file-atomic "^4.0.0" + cmd-shim "^6.0.0" + npm-normalize-package-bin "^3.0.0" + read-cmd-shim "^4.0.0" + write-file-atomic "^5.0.0" binary-extensions@^2.0.0: version "2.2.0" @@ -1802,6 +1901,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builtins@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" @@ -1819,7 +1926,7 @@ byte-size@7.0.0: resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-7.0.0.tgz#36528cd1ca87d39bd9abd51f5715dc93b6ceb032" integrity sha512-NNiBxKgxybMBtWdmvx7ZITJi4ZG+CYUgwOSZTfqB1qogkRHrhbQE/R2r5Fh94X+InN5MCYz6SvB/ejHMj/HbsQ== -cacache@^16.0.0, cacache@^16.0.6, cacache@^16.1.0: +cacache@^16.0.0, cacache@^16.1.0: version "16.1.3" resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.3.tgz#a02b9f34ecfaf9a78c9f4bc16fceb94d5d67a38e" integrity sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ== @@ -1843,14 +1950,14 @@ cacache@^16.0.0, cacache@^16.0.6, cacache@^16.1.0: tar "^6.1.11" unique-filename "^2.0.0" -cacache@^17.0.0: - version "17.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-17.0.4.tgz#5023ed892ba8843e3b7361c26d0ada37e146290c" - integrity sha512-Z/nL3gU+zTUjz5pCA5vVjYM8pmaw2kxM7JEiE0fv3w77Wj+sFbi70CrBruUWH0uNcEdvLDixFpgA2JM4F4DBjA== +cacache@^17.0.0, cacache@^17.0.4: + version "17.0.5" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-17.0.5.tgz#6dbec26c11f1f6a2b558bc11ed3316577c339ebc" + integrity sha512-Y/PRQevNSsjAPWykl9aeGz8Pr+OI6BYM9fYDNMvOkuUiG9IhG4LEmaYrZZZvioMUEQ+cBCxT0v8wrnCURccyKA== dependencies: "@npmcli/fs" "^3.1.0" fs-minipass "^3.0.0" - glob "^8.0.1" + glob "^9.3.1" lru-cache "^7.7.1" minipass "^4.0.0" minipass-collect "^1.0.2" @@ -1980,6 +2087,11 @@ chalk@4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@5.2.0, chalk@^5.0.1, chalk@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.2.0.tgz#249623b7d66869c673699fb66d65723e54dfcfb3" + integrity sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA== + chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1997,11 +2109,6 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.0.1, chalk@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.2.0.tgz#249623b7d66869c673699fb66d65723e54dfcfb3" - integrity sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA== - chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -2073,22 +2180,19 @@ cli-progress@^3.4.0: dependencies: string-width "^4.2.3" -cli-spinners@2.6.1: +cli-spinners@2.6.1, cli-spinners@^2.5.0: version "2.6.1" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== -cli-spinners@^2.5.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.7.0.tgz#f815fd30b5f9eaac02db604c7a231ed7cb2f797a" - integrity sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw== - -cli-table@^0.3.11: - version "0.3.11" - resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.11.tgz#ac69cdecbe81dccdba4889b9a18b7da312a9d3ee" - integrity sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ== +cli-table3@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" + integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== dependencies: - colors "1.0.3" + string-width "^4.2.0" + optionalDependencies: + "@colors/colors" "1.5.0" cli-truncate@^2.1.0: version "2.1.0" @@ -2184,13 +2288,18 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== -cmd-shim@5.0.0, cmd-shim@^5.0.0: +cmd-shim@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-5.0.0.tgz#8d0aaa1a6b0708630694c4dbde070ed94c707724" integrity sha512-qkCtZ59BidfEwHltnJwkyVZn+XQojdAySM1D1gSeh11Z4pW1Kpolkyo53L5noc0nrxmIvyFwTmJRo4xs7FFLPw== dependencies: mkdirp-infer-owner "^2.0.0" +cmd-shim@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-6.0.1.tgz#a65878080548e1dca760b3aea1e21ed05194da9d" + integrity sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2225,11 +2334,6 @@ colorette@^2.0.19: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== -colors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw== - columnify@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.6.0.tgz#6989531713c9008bb29735e61e37acf5bd553cf3" @@ -2328,7 +2432,7 @@ concat-stream@^2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" -config-chain@1.1.12: +config-chain@1.1.12, config-chain@^1.1.11: version "1.1.12" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" integrity sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA== @@ -2336,14 +2440,6 @@ config-chain@1.1.12: ini "^1.3.4" proto-list "~1.2.1" -config-chain@^1.1.11: - version "1.1.13" - resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" - integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== - dependencies: - ini "^1.3.4" - proto-list "~1.2.1" - configstore@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/configstore/-/configstore-6.0.0.tgz#49eca2ebc80983f77e09394a1a56e0aca8235566" @@ -2360,7 +2456,7 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== -conventional-changelog-angular@5.0.12: +conventional-changelog-angular@5.0.12, conventional-changelog-angular@^5.0.11: version "5.0.12" resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-5.0.12.tgz#c979b8b921cbfe26402eb3da5bbfda02d865a2b9" integrity sha512-5GLsbnkR/7A89RyHLvvoExbiGbd9xKdKqDTrArnPbOqBqG/2wIosu0fHwpeIRI8Tl94MhVNBXcLJZl92ZQ5USw== @@ -2368,14 +2464,6 @@ conventional-changelog-angular@5.0.12: compare-func "^2.0.0" q "^1.5.1" -conventional-changelog-angular@^5.0.11: - version "5.0.13" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz#896885d63b914a70d4934b59d2fe7bde1832b28c" - integrity sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA== - dependencies: - compare-func "^2.0.0" - q "^1.5.1" - conventional-changelog-conventionalcommits@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz#41bdce54eb65a848a4a3ffdca93e92fa22b64a86" @@ -2525,6 +2613,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-random-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" + integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== + crypto-random-string@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-4.0.0.tgz#5a3cc53d7dd86183df5da0312816ceeeb5bb1fc2" @@ -2532,6 +2625,11 @@ crypto-random-string@^4.0.0: dependencies: type-fest "^1.0.1" +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + cz-conventional-changelog@3.3.0, cz-conventional-changelog@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz#9246947c90404149b3fe2cf7ee91acad3b7d22d2" @@ -2570,11 +2668,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debuglog@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" - integrity sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw== - decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" @@ -2654,6 +2747,20 @@ define-properties@^1.1.3, define-properties@^1.1.4: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +del@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/del/-/del-6.1.1.tgz#3b70314f1ec0aa325c6b14eb36b95786671edb7a" + integrity sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg== + dependencies: + globby "^11.0.1" + graceful-fs "^4.2.4" + is-glob "^4.0.1" + is-path-cwd "^2.2.0" + is-path-inside "^3.0.2" + p-map "^4.0.0" + rimraf "^3.0.2" + slash "^3.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -2689,14 +2796,6 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g== -dezalgo@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" - integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== - dependencies: - asap "^2.0.0" - wrappy "1" - diff@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" @@ -2769,6 +2868,11 @@ electron-to-chromium@^1.4.284: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.311.tgz#953bc9a4767f5ce8ec125f9a1ad8e00e8f67e479" integrity sha512-RoDlZufvrtr2Nx3Yx5MB8jX3aHIxm8nRWPJm3yVvyHmyKaRvn90RjzB6hNnt0AkhS3IInJdyRfQb4mWhPvUjVw== +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -2941,10 +3045,10 @@ escodegen@^1.13.0: optionalDependencies: source-map "~0.6.1" -eslint-config-prettier@^8.6.0: - version "8.6.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz#dec1d29ab728f4fa63061774e1672ac4e363d207" - integrity sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA== +eslint-config-prettier@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz#bfda738d412adc917fd7b038857110efe98c9348" + integrity sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA== eslint-import-resolver-node@^0.3.7: version "0.3.7" @@ -3096,13 +3200,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@^8.35.0: - version "8.35.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.35.0.tgz#fffad7c7e326bae606f0e8f436a6158566d42323" - integrity sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw== +eslint@^8.36.0: + version "8.36.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.36.0.tgz#1bd72202200a5492f91803b113fb8a83b11285cf" + integrity sha512-Y956lmS7vDqomxlaaQAHVmeb4tNMp2FWIvU/RnU5BD3IKMD/MJPr76xdyr68P8tV1iNMvN2mRK0yy3c+UjL+bw== dependencies: - "@eslint/eslintrc" "^2.0.0" - "@eslint/js" "8.35.0" + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.4.0" + "@eslint/eslintrc" "^2.0.1" + "@eslint/js" "8.36.0" "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -3113,9 +3219,8 @@ eslint@^8.35.0: doctrine "^3.0.0" escape-string-regexp "^4.0.0" eslint-scope "^7.1.1" - eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.4.0" + espree "^9.5.0" esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -3137,15 +3242,14 @@ eslint@^8.35.0: minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" - regexpp "^3.2.0" strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" -espree@^9.0.0, espree@^9.4.0: - version "9.4.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.1.tgz#51d6092615567a2c2cff7833445e37c28c0065bd" - integrity sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg== +espree@^9.0.0, espree@^9.5.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.0.tgz#3646d4e3f58907464edba852fa047e6a27bdf113" + integrity sha512-JPbJGhKc47++oo4JkEoTe2wjy4fmMwvFpgJT9cQzmfXKp22Dr6Hf1tdCteLz1h0P3t+mGvWZ+4Uankvh8+c6zw== dependencies: acorn "^8.8.0" acorn-jsx "^5.3.2" @@ -3185,11 +3289,21 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -3220,14 +3334,14 @@ execa@^5.0.0, execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -execa@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20" - integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA== +execa@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-7.1.1.tgz#3eb3c83d239488e7b409d48e8813b76bb55c9c43" + integrity sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q== dependencies: cross-spawn "^7.0.3" get-stream "^6.0.1" - human-signals "^3.0.1" + human-signals "^4.3.0" is-stream "^3.0.0" merge-stream "^2.0.0" npm-run-path "^5.1.0" @@ -3324,6 +3438,11 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-url@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/file-url/-/file-url-3.0.0.tgz#247a586a746ce9f7a8ed05560290968afc262a77" + integrity sha512-g872QGsHexznxkIAdK8UiZRe7SkE6kvylShU4Nsj8NvfvZag7S0QuQ4IgvPDkk75HxgjIVDwycFTDAgIiO4nDA== + filelist@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -3553,6 +3672,20 @@ gauge@^4.0.3: strip-ansi "^6.0.1" wide-align "^1.1.5" +gauge@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-5.0.0.tgz#e270ca9d97dae84abf64e5277ef1ebddc7dd1e2f" + integrity sha512-0s5T5eciEG7Q3ugkxAkFtaDhrrhXsCRivA5y8C9WMHWuI8UlMOJg7+Iwf7Mccii+Dfs3H5jHepU0joPVyQU0Lw== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.3" + console-control-strings "^1.1.0" + has-unicode "^2.0.1" + signal-exit "^3.0.7" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.5" + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -3735,6 +3868,16 @@ glob@^8.0.0, glob@^8.0.1: minimatch "^5.0.1" once "^1.3.0" +glob@^9.2.0, glob@^9.3.1: + version "9.3.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.2.tgz#8528522e003819e63d11c979b30896e0eaf52eda" + integrity sha512-BTv/JhKXFEHsErMte/AnfiSv8yYOLLiyH2lTg8vn02O21zWFgHPTfxtgn1QRe7NRgggUhC8hacR2Re94svHqeA== + dependencies: + fs.realpath "^1.0.0" + minimatch "^7.4.1" + minipass "^4.2.4" + path-scurry "^1.6.1" + global-dirs@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" @@ -3793,7 +3936,7 @@ globalyzer@0.1.0: resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== -globby@11.1.0, globby@^11.0.4, globby@^11.1.0: +globby@11.1.0, globby@^11.0.1, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -3974,7 +4117,7 @@ hosted-git-info@^5.0.0, hosted-git-info@^5.1.0: dependencies: lru-cache "^7.5.1" -hosted-git-info@^6.0.0: +hosted-git-info@^6.0.0, hosted-git-info@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-6.1.1.tgz#629442c7889a69c05de604d52996b74fe6f26d58" integrity sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w== @@ -4021,10 +4164,10 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -human-signals@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" - integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== +human-signals@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" + integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== humanize-ms@^1.2.1: version "1.2.1" @@ -4057,7 +4200,7 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -4140,10 +4283,10 @@ ini@^1.3.2, ini@^1.3.4, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -ini@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.1.tgz#c76ec81007875bc44d544ff7a11a55d12294102d" - integrity sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ== +ini@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-4.0.0.tgz#35b4b0ba3bb9a3feb8c50dbf92fb1671efda88eb" + integrity sha512-t0ikzf5qkSFqRl1e6ejKBe+Tk2bsQd8ivEkcisyGXsku2t8NvXZ1Y3RRz5vxrDgOrTBOi13CvGsVoI5wVpd7xg== init-package-json@3.0.2, init-package-json@^3.0.2: version "3.0.2" @@ -4158,6 +4301,27 @@ init-package-json@3.0.2, init-package-json@^3.0.2: validate-npm-package-license "^3.0.4" validate-npm-package-name "^4.0.0" +inquirer@8.2.4: + version "8.2.4" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.4.tgz#ddbfe86ca2f67649a67daa6f1051c128f684f0b4" + integrity sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^7.0.0" + inquirer@8.2.5, inquirer@^8.2.4: version "8.2.5" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.5.tgz#d8654a7542c35a9b9e069d27e2df4858784d54f8" @@ -4334,6 +4498,11 @@ is-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== +is-path-cwd@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" + integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== + is-path-inside@^3.0.2, is-path-inside@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" @@ -4383,16 +4552,11 @@ is-ssh@^1.4.0: dependencies: protocols "^2.0.1" -is-stream@2.0.0: +is-stream@2.0.0, is-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - is-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" @@ -4722,10 +4886,10 @@ just-diff-apply@^5.2.0: resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-5.5.0.tgz#771c2ca9fa69f3d2b54e7c3f5c1dfcbcc47f9f0f" integrity sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw== -just-diff@^5.0.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-5.2.0.tgz#60dca55891cf24cd4a094e33504660692348a241" - integrity sha512-6ufhP9SHjb7jibNFrNxyFZ6od3g+An6Ai9mhGRvcYe8UJlH0prseN64M+6ZBBUoKYHZsitDP42gAJ8+eVWr3lw== +just-diff@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.0.tgz#77ebd7ee7fbb22f887dbe1dc376e5f781ea0c9c5" + integrity sha512-MbEkhMEa9p7bVD/U29cTM6zUHcLPWwSZjnSquOu++6GyKfoc3aGNdsp2uLQ3Zq0ocg60MzTxwB9qjqwgoQu3+g== keyv@^4.5.2: version "4.5.2" @@ -4758,14 +4922,15 @@ latest-version@^7.0.0: dependencies: package-json "^8.1.0" -lerna@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/lerna/-/lerna-6.5.1.tgz#eb89698e5b2891f5681f39d980f63d0519fc464f" - integrity sha512-Va1bysubwWdoWZ1ncKcoTGBXNAu/10/TwELb550TTivXmEWjCCdek4eX0BNLTEYKxu3tpV2UEeqVisUiWGn4WA== +lerna@^6.6.0: + version "6.6.0" + resolved "https://registry.yarnpkg.com/lerna/-/lerna-6.6.0.tgz#385dcf9a88ddd9e62112f03bcfc2ce9e75e64980" + integrity sha512-E1uxpcKVkXPL7IZd8jXmoGlES878M7ConEpkX1o+KTs99ir9c3StIOnrhULYHP3VacUyjtxAPyPCCSg6rIipaw== dependencies: - "@lerna/child-process" "6.5.1" - "@lerna/create" "6.5.1" - "@npmcli/arborist" "5.3.0" + "@lerna/child-process" "6.6.0" + "@lerna/create" "6.6.0" + "@lerna/legacy-package-management" "6.6.0" + "@npmcli/arborist" "6.2.3" "@npmcli/run-script" "4.1.7" "@nrwl/devkit" ">=15.5.2 < 16" "@octokit/plugin-enterprise-rest" "6.0.1" @@ -4807,7 +4972,7 @@ lerna@^6.5.1: node-fetch "2.6.7" npm-package-arg "8.1.1" npm-packlist "5.1.1" - npm-registry-fetch "13.3.0" + npm-registry-fetch "^14.0.3" npmlog "^6.0.2" nx ">=15.5.2 < 16" p-map "4.0.0" @@ -4816,14 +4981,14 @@ lerna@^6.5.1: p-queue "6.6.2" p-reduce "2.1.0" p-waterfall "2.1.1" - pacote "13.6.1" + pacote "13.6.2" path-exists "4.0.0" pify "5.0.0" read-cmd-shim "3.0.0" read-package-json "5.0.1" resolve-from "5.0.0" rimraf "^3.0.2" - semver "7.3.4" + semver "^7.3.8" signal-exit "3.0.7" slash "3.0.0" ssri "9.0.1" @@ -4877,10 +5042,10 @@ libnpmpublish@6.0.4: semver "^7.3.7" ssri "^9.0.0" -lilconfig@2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== +lilconfig@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== lines-and-columns@^1.1.6: version "1.2.4" @@ -4899,29 +5064,29 @@ linkify-it@^3.0.1: dependencies: uc.micro "^1.0.1" -lint-staged@>=13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.1.2.tgz#443636a0cfd834d5518d57d228130dc04c83d6fb" - integrity sha512-K9b4FPbWkpnupvK3WXZLbgu9pchUJ6N7TtVZjbaPsoizkqFUDkUReUL25xdrCljJs7uLUF3tZ7nVPeo/6lp+6w== +lint-staged@>=13.2.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.2.0.tgz#b7abaf79c91cd36d824f17b23a4ce5209206126a" + integrity sha512-GbyK5iWinax5Dfw5obm2g2ccUiZXNGtAS4mCbJ0Lv4rq6iEtfBSjOYdcbOtAIFtM114t0vdpViDDetjVTSd8Vw== dependencies: + chalk "5.2.0" cli-truncate "^3.1.0" - colorette "^2.0.19" - commander "^9.4.1" + commander "^10.0.0" debug "^4.3.4" - execa "^6.1.0" - lilconfig "2.0.6" - listr2 "^5.0.5" + execa "^7.0.0" + lilconfig "2.1.0" + listr2 "^5.0.7" micromatch "^4.0.5" normalize-path "^3.0.0" - object-inspect "^1.12.2" + object-inspect "^1.12.3" pidtree "^0.6.0" string-argv "^0.3.1" - yaml "^2.1.3" + yaml "^2.2.1" -listr2@^5.0.5: - version "5.0.7" - resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.7.tgz#de69ccc4caf6bea7da03c74f7a2ffecf3904bd53" - integrity sha512-MD+qXHPmtivrHIDRwPYdfNkrzqDiuaKU/rfBcec3WMyMF3xylQj3jMq344OtvQxz7zaCFViRAeqlr2AFhPvXHw== +listr2@^5.0.7: + version "5.0.8" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.8.tgz#a9379ffeb4bd83a68931a65fb223a11510d6ba23" + integrity sha512-mC73LitKHj9w6v30nLNGPetZIlfpUniNSsxxrbaPcWOjDb92SHPzJPi/t+v1YC/lxKz/AJ9egOjww0qUuFxBpA== dependencies: cli-truncate "^2.1.0" colorette "^2.0.19" @@ -5098,10 +5263,10 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1: - version "7.17.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.17.0.tgz#00c7ba5919e5ea7c69ff94ddabbf32cb09ab805c" - integrity sha512-zSxlVVwOabhVyTi6E8gYv2cr6bXK+8ifYz5/uyJb9feXX6NACVDwY4p5Ut3WC3Ivo/QhpARHU3iujx2xGAYHbQ== +lru-cache@^7.14.1, lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== lunr@^2.3.9: version "2.3.9" @@ -5296,7 +5461,7 @@ minimatch@3.0.5: dependencies: brace-expansion "^1.1.7" -minimatch@5.0.1: +minimatch@5.0.1, minimatch@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== @@ -5310,24 +5475,17 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^6.1.0, minimatch@^6.1.6, minimatch@^6.2.0: +minimatch@^6.1.0, minimatch@^6.1.6: version "6.2.0" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-6.2.0.tgz#2b70fd13294178c69c04dfc05aebdb97a4e79e42" integrity sha512-sauLxniAmvnhhRjFwPNnJKaPFYyddAgbYdeUpHULtCT/GhzdCx/MDNy+Y40lBxTQUrMzDE8e0S43Z5uqfO0REg== dependencies: brace-expansion "^2.0.1" -minimatch@^7.1.3: - version "7.2.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.2.0.tgz#741ff59370007ebb8faff0a9b20cdb44357803e4" - integrity sha512-rMRHmwySzopAQjmWW6TkAKCEDKNaY/HuV/c2YkWWuWnfkTwApt0V4hnYzzPnZ/5Gcd2+8MPncSyuOGPl3xPvcg== +minimatch@^7.1.3, minimatch@^7.4.1, minimatch@^7.4.2, minimatch@^7.4.3: + version "7.4.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.3.tgz#012cbf110a65134bb354ae9773b55256cdb045a2" + integrity sha512-5UB4yYusDtkRPbRiy1cqZ1IpGNcJCGlEMG17RKzPddpyiPKoCdwohbED8g4QXT0ewCt8LTkQXuljsUfQ3FKM4A== dependencies: brace-expansion "^2.0.1" @@ -5340,16 +5498,11 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@1.2.7: +minimist@1.2.7, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" @@ -5415,10 +5568,10 @@ minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: dependencies: yallist "^4.0.0" -minipass@^4.0.0: - version "4.2.4" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.4.tgz#7d0d97434b6a19f59c5c3221698b48bbf3b2cd06" - integrity sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ== +minipass@^4.0.0, minipass@^4.0.2, minipass@^4.2.4: + version "4.2.5" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.5.tgz#9e0e5256f1e3513f8c34691dd68549e85b2c8ceb" + integrity sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q== minizlib@^2.1.1, minizlib@^2.1.2: version "2.1.2" @@ -5550,20 +5703,13 @@ node-addon-api@^3.2.1: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== -node-fetch@2.6.7: +node-fetch@2.6.7, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.7: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== - dependencies: - whatwg-url "^5.0.0" - node-gyp-build@^4.3.0: version "4.6.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" @@ -5605,13 +5751,6 @@ node-wifi@^2.0.16: command-line-args "^5.2.0" command-line-usage "^6.1.1" -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - nopt@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" @@ -5619,6 +5758,13 @@ nopt@^6.0.0: dependencies: abbrev "^1.0.0" +nopt@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.1.0.tgz#91f6a3366182176e72ecab93a09c19b63b485f28" + integrity sha512-ZFPLe9Iu0tnx7oWhFxAo4s7QTn8+NNDDxYNaKLjE7Dp0tbakQ3M1QhQzsnzXHQBTUO3K9BmwaxnyO8Ayn2I95Q== + dependencies: + abbrev "^2.0.0" + normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -5676,13 +5822,6 @@ npm-bundled@^1.1.1, npm-bundled@^1.1.2: dependencies: npm-normalize-package-bin "^1.0.1" -npm-bundled@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-2.0.1.tgz#94113f7eb342cd7a67de1e789f896b04d2c600f4" - integrity sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw== - dependencies: - npm-normalize-package-bin "^2.0.0" - npm-bundled@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-3.0.0.tgz#7e8e2f8bb26b794265028491be60321a25a39db7" @@ -5690,13 +5829,13 @@ npm-bundled@^3.0.0: dependencies: npm-normalize-package-bin "^3.0.0" -npm-check-updates@^16.7.9: - version "16.7.9" - resolved "https://registry.yarnpkg.com/npm-check-updates/-/npm-check-updates-16.7.9.tgz#b30e21eb27a9b9f590f96ef9eb0f128a09aaf216" - integrity sha512-2/T9PB0W1uABB1N11jdnPauqNLXMhPUyC0DBeeGlUktcGl/412f40iqFsnNGCqIAag/C37SjLNiW2/yuD7Salw== +npm-check-updates@^16.8.0: + version "16.8.0" + resolved "https://registry.yarnpkg.com/npm-check-updates/-/npm-check-updates-16.8.0.tgz#43884cc479f03d274c9ca9e3150e6fb64412377e" + integrity sha512-2xpFtUPa2OyswC8kpIpxRqQcP/D0uqaBgfiOQAtvGlnDhmHr3QwR2gWjjWtS6xnNvgyyVly/4edY0Uu24c46ew== dependencies: chalk "^5.2.0" - cli-table "^0.3.11" + cli-table3 "^0.6.3" commander "^10.0.0" fast-memoize "^2.5.2" find-up "5.0.0" @@ -5704,19 +5843,19 @@ npm-check-updates@^16.7.9: get-stdin "^8.0.0" globby "^11.0.4" hosted-git-info "^5.1.0" - ini "^3.0.1" + ini "^4.0.0" json-parse-helpfulerror "^1.0.3" jsonlines "^0.1.1" lodash "^4.17.21" - minimatch "^6.2.0" + minimatch "^7.4.3" p-map "^4.0.0" - pacote "15.1.0" + pacote "15.1.1" parse-github-url "^1.0.2" progress "^2.0.3" prompts-ncu "^2.5.1" rc-config-loader "^4.1.2" remote-git-tags "^3.0.0" - rimraf "^4.1.2" + rimraf "^4.4.1" semver "^7.3.8" semver-utils "^1.1.4" source-map-support "^0.5.21" @@ -5764,7 +5903,7 @@ npm-package-arg@8.1.1: semver "^7.0.0" validate-npm-package-name "^3.0.0" -npm-package-arg@^10.0.0: +npm-package-arg@^10.0.0, npm-package-arg@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-10.1.0.tgz#827d1260a683806685d17193073cc152d3c7e9b1" integrity sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA== @@ -5784,7 +5923,7 @@ npm-package-arg@^9.0.0, npm-package-arg@^9.0.1: semver "^7.3.5" validate-npm-package-name "^4.0.0" -npm-packlist@5.1.1: +npm-packlist@5.1.1, npm-packlist@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-5.1.1.tgz#79bcaf22a26b6c30aa4dd66b976d69cc286800e0" integrity sha512-UfpSvQ5YKwctmodvPPkK6Fwk603aoVsf8AEbmVKAEECrfvL8SSe1A2YIwrJ6xmTHAITKPwwZsWo7WwEbNk0kxw== @@ -5794,16 +5933,6 @@ npm-packlist@5.1.1: npm-bundled "^1.1.2" npm-normalize-package-bin "^1.0.1" -npm-packlist@^5.1.0: - version "5.1.3" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-5.1.3.tgz#69d253e6fd664b9058b85005905012e00e69274b" - integrity sha512-263/0NGrn32YFYi4J533qzrQ/krmmrWwhKkzwTuM4f/07ug51odoaNjUexxO4vxlzURHcmYMH1QjvHjsNDKLVg== - dependencies: - glob "^8.0.1" - ignore-walk "^5.0.1" - npm-bundled "^2.0.0" - npm-normalize-package-bin "^2.0.0" - npm-packlist@^7.0.0: version "7.0.4" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-7.0.4.tgz#033bf74110eb74daf2910dc75144411999c5ff32" @@ -5821,7 +5950,7 @@ npm-pick-manifest@^7.0.0: npm-package-arg "^9.0.0" semver "^7.3.5" -npm-pick-manifest@^8.0.0: +npm-pick-manifest@^8.0.0, npm-pick-manifest@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-8.0.1.tgz#c6acd97d1ad4c5dbb80eac7b386b03ffeb289e5f" integrity sha512-mRtvlBjTsJvfCCdmPtiu2bdlx8d/KXtF7yNXNWe7G0Z36qWA9Ny5zXsI2PfBZEv7SXgoxTmNaTzGSbbzDZChoA== @@ -5831,18 +5960,18 @@ npm-pick-manifest@^8.0.0: npm-package-arg "^10.0.0" semver "^7.3.5" -npm-registry-fetch@13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-13.3.0.tgz#0ce10fa4a699a1e70685ecf41bbfb4150d74231b" - integrity sha512-10LJQ/1+VhKrZjIuY9I/+gQTvumqqlgnsCufoXETHAPFTS3+M+Z5CFhZRDHGavmJ6rOye3UvNga88vl8n1r6gg== +npm-registry-fetch@14.0.3, npm-registry-fetch@^14.0.0, npm-registry-fetch@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-14.0.3.tgz#8545e321c2b36d2c6fe6e009e77e9f0e527f547b" + integrity sha512-YaeRbVNpnWvsGOjX2wk5s85XJ7l1qQBGAp724h8e2CZFFhMSuw9enom7K1mWVUtvXO1uUSFIAPofQK0pPN0ZcA== dependencies: - make-fetch-happen "^10.0.6" - minipass "^3.1.6" - minipass-fetch "^2.0.3" + make-fetch-happen "^11.0.0" + minipass "^4.0.0" + minipass-fetch "^3.0.0" minipass-json-stream "^1.0.1" minizlib "^2.1.2" - npm-package-arg "^9.0.1" - proc-log "^2.0.0" + npm-package-arg "^10.0.0" + proc-log "^3.0.0" npm-registry-fetch@^13.0.0, npm-registry-fetch@^13.0.1: version "13.3.1" @@ -5857,19 +5986,6 @@ npm-registry-fetch@^13.0.0, npm-registry-fetch@^13.0.1: npm-package-arg "^9.0.1" proc-log "^2.0.0" -npm-registry-fetch@^14.0.0: - version "14.0.3" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-14.0.3.tgz#8545e321c2b36d2c6fe6e009e77e9f0e527f547b" - integrity sha512-YaeRbVNpnWvsGOjX2wk5s85XJ7l1qQBGAp724h8e2CZFFhMSuw9enom7K1mWVUtvXO1uUSFIAPofQK0pPN0ZcA== - dependencies: - make-fetch-happen "^11.0.0" - minipass "^4.0.0" - minipass-fetch "^3.0.0" - minipass-json-stream "^1.0.1" - minizlib "^2.1.2" - npm-package-arg "^10.0.0" - proc-log "^3.0.0" - npm-run-all@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba" @@ -5899,7 +6015,7 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" -npmlog@^6.0.0, npmlog@^6.0.2: +npmlog@6.0.2, npmlog@^6.0.0, npmlog@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830" integrity sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg== @@ -5909,6 +6025,16 @@ npmlog@^6.0.0, npmlog@^6.0.2: gauge "^4.0.3" set-blocking "^2.0.0" +npmlog@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-7.0.1.tgz#7372151a01ccb095c47d8bf1d0771a4ff1f53ac8" + integrity sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg== + dependencies: + are-we-there-yet "^4.0.0" + console-control-strings "^1.1.0" + gauge "^5.0.0" + set-blocking "^2.0.0" + nx@15.7.2, "nx@>=15.5.2 < 16": version "15.7.2" resolved "https://registry.yarnpkg.com/nx/-/nx-15.7.2.tgz#048f8968420f5d56a1f464a83c8c3e84dfc95bf4" @@ -5993,7 +6119,7 @@ nyc@^15.1.0: test-exclude "^6.0.0" yargs "^15.0.2" -object-inspect@^1.12.2, object-inspect@^1.9.0: +object-inspect@^1.12.2, object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== @@ -6234,10 +6360,10 @@ package-json@^8.1.0: registry-url "^6.0.0" semver "^7.3.7" -pacote@13.6.1: - version "13.6.1" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-13.6.1.tgz#ac6cbd9032b4c16e5c1e0c60138dfe44e4cc589d" - integrity sha512-L+2BI1ougAPsFjXRyBhcKmfT016NscRFLv6Pz5EiNf1CCFJFU0pSKKQwsZTyAQB+sTuUL4TyFyp6J1Ork3dOqw== +pacote@13.6.2, pacote@^13.6.1: + version "13.6.2" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-13.6.2.tgz#0d444ba3618ab3e5cd330b451c22967bbd0ca48a" + integrity sha512-Gu8fU3GsvOPkak2CkbojR7vjs3k3P9cA6uazKTHdsdV0gpCEQq2opelnEv30KRQWgVzP5Vd/5umjcedma3MKtg== dependencies: "@npmcli/git" "^3.0.0" "@npmcli/installed-package-contents" "^1.0.7" @@ -6261,10 +6387,10 @@ pacote@13.6.1: ssri "^9.0.0" tar "^6.1.11" -pacote@15.1.0: - version "15.1.0" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.1.0.tgz#2e0b12a4f55ffd801a8134a1ae28ef361dc3f243" - integrity sha512-FFcjtIl+BQNfeliSm7MZz5cpdohvUV1yjGnqgVM4UnVF7JslRY0ImXAygdaCDV0jjUADEWu4y5xsDV8brtrTLg== +pacote@15.1.1, pacote@^15.0.0, pacote@^15.0.8: + version "15.1.1" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.1.1.tgz#94d8c6e0605e04d427610b3aacb0357073978348" + integrity sha512-eeqEe77QrA6auZxNHIp+1TzHQ0HBKf5V6c8zcaYZ134EJe1lCi+fjXATkNiEEfbG+e50nu02GLvUtmZcGOYabQ== dependencies: "@npmcli/git" "^4.0.0" "@npmcli/installed-package-contents" "^2.0.1" @@ -6285,33 +6411,6 @@ pacote@15.1.0: ssri "^10.0.0" tar "^6.1.11" -pacote@^13.0.3, pacote@^13.6.1: - version "13.6.2" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-13.6.2.tgz#0d444ba3618ab3e5cd330b451c22967bbd0ca48a" - integrity sha512-Gu8fU3GsvOPkak2CkbojR7vjs3k3P9cA6uazKTHdsdV0gpCEQq2opelnEv30KRQWgVzP5Vd/5umjcedma3MKtg== - dependencies: - "@npmcli/git" "^3.0.0" - "@npmcli/installed-package-contents" "^1.0.7" - "@npmcli/promise-spawn" "^3.0.0" - "@npmcli/run-script" "^4.1.0" - cacache "^16.0.0" - chownr "^2.0.0" - fs-minipass "^2.1.0" - infer-owner "^1.0.4" - minipass "^3.1.6" - mkdirp "^1.0.4" - npm-package-arg "^9.0.0" - npm-packlist "^5.1.0" - npm-pick-manifest "^7.0.0" - npm-registry-fetch "^13.0.1" - proc-log "^2.0.0" - promise-retry "^2.0.1" - read-package-json "^5.0.0" - read-package-json-fast "^2.0.3" - rimraf "^3.0.2" - ssri "^9.0.0" - tar "^6.1.11" - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6319,13 +6418,13 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-conflict-json@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/parse-conflict-json/-/parse-conflict-json-2.0.2.tgz#3d05bc8ffe07d39600dc6436c6aefe382033d323" - integrity sha512-jDbRGb00TAPFsKWCpZZOT93SxVP9nONOSgES3AevqRq/CHvavEBvKAjxX9p5Y5F0RZLxH9Ufd9+RwtCsa+lFDA== +parse-conflict-json@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz#67dc55312781e62aa2ddb91452c7606d1969960c" + integrity sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw== dependencies: - json-parse-even-better-errors "^2.3.1" - just-diff "^5.0.1" + json-parse-even-better-errors "^3.0.0" + just-diff "^6.0.0" just-diff-apply "^5.2.0" parse-github-url@^1.0.2: @@ -6413,6 +6512,14 @@ path-parse@^1.0.6, path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.6.1: + version "1.6.3" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.6.3.tgz#4eba7183d64ef88b63c7d330bddc3ba279dc6c40" + integrity sha512-RAmB+n30SlN+HnNx6EbcpoDy9nwdpcGPnEKrJnu6GZoDWBdIjo1UQMVtW2ybtC7LC2oKLcMq8y5g8WnKLiod9g== + dependencies: + lru-cache "^7.14.1" + minipass "^4.0.2" + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -6485,6 +6592,14 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +postcss-selector-parser@^6.0.10: + version "6.0.11" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc" + integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -6502,10 +6617,19 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@2.8.4: - version "2.8.4" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3" - integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw== +prettier@2.8.6: + version "2.8.6" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.6.tgz#5c174b29befd507f14b83e3c19f83fdc0e974b71" + integrity sha512-mtuzdiBbHwPEgl7NxWlqOkithPyp4VN93V7VeHVWBF+ad3I5avc0RVDT4oImXQy9H/AqxA2NSQH8pSxHW6FYbQ== + +pretty-format@29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.4.3.tgz#25500ada21a53c9e8423205cf0337056b201244c" + integrity sha512-cvpcHTc42lcsvOOAzd3XuNWTcvk1Jmnzqeu+WsOuiPmxUJTnkbAcFNsRKvEpBEUFVUgy/GTZLulZDcDEi+CIlA== + dependencies: + "@jest/schemas" "^29.4.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" proc-log@^2.0.0, proc-log@^2.0.1: version "2.0.1" @@ -6529,6 +6653,11 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -6685,17 +6814,22 @@ rc@1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + read-cmd-shim@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-3.0.0.tgz#62b8c638225c61e6cc607f8f4b779f3b8238f155" integrity sha512-KQDVjGqhZk92PPNRj9ZEXEuqg8bUobSKRw+q0YQ3TKI5xkce7bUJobL4Z/OtiEbAAv70yEpYIXp4iQ9L8oPVog== -read-cmd-shim@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-3.0.1.tgz#868c235ec59d1de2db69e11aec885bc095aea087" - integrity sha512-kEmDUoYf/CDy8yZbLTmhB1X9kkjf9Q80PCNsDMb7ufrGd6zZSQA1+UyjrO+pZm5K/S4OXCWJeiIt1JA8kAsa6g== +read-cmd-shim@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" + integrity sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q== -read-package-json-fast@^2.0.2, read-package-json-fast@^2.0.3: +read-package-json-fast@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz#323ca529630da82cb34b36cc0b996693c98c2b83" integrity sha512-W/BKtbL+dUjTuRL2vziuYhp76s5HZ9qQhd/dKfWIZveD0O40453QNyZhC0e63lqZrAQ4jiOapVoeJ7JrszenQQ== @@ -6703,7 +6837,7 @@ read-package-json-fast@^2.0.2, read-package-json-fast@^2.0.3: json-parse-even-better-errors "^2.3.0" npm-normalize-package-bin "^1.0.1" -read-package-json-fast@^3.0.0: +read-package-json-fast@^3.0.0, read-package-json-fast@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz#394908a9725dc7a5f14e70c8e7556dff1d2b1049" integrity sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw== @@ -6711,7 +6845,7 @@ read-package-json-fast@^3.0.0: json-parse-even-better-errors "^3.0.0" npm-normalize-package-bin "^3.0.0" -read-package-json@5.0.1: +read-package-json@5.0.1, read-package-json@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-5.0.1.tgz#1ed685d95ce258954596b13e2e0e76c7d0ab4c26" integrity sha512-MALHuNgYWdGW3gKzuNMuYtcSSZbGQm94fAp16xt8VsYTLBjUSc55bLMKe6gzpWue0Tfi6CBgwCSdDAqutGDhMg== @@ -6721,16 +6855,6 @@ read-package-json@5.0.1: normalize-package-data "^4.0.0" npm-normalize-package-bin "^1.0.1" -read-package-json@^5.0.0: - version "5.0.2" - resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-5.0.2.tgz#b8779ccfd169f523b67208a89cc912e3f663f3fa" - integrity sha512-BSzugrt4kQ/Z0krro8zhTwV1Kd79ue25IhNN/VtHFy1mG/6Tluyi+msc0UpwaoQzxSHa28mntAjIZY6kEgfR9Q== - dependencies: - glob "^8.0.1" - json-parse-even-better-errors "^2.3.1" - normalize-package-data "^4.0.0" - npm-normalize-package-bin "^2.0.0" - read-package-json@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-6.0.0.tgz#6a741841ad72a40e77a82b9c3c8c10e865bbc519" @@ -6793,6 +6917,16 @@ readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stre string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.3.0.tgz#0914d0c72db03b316c9733bb3461d64a3cc50cba" + integrity sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" @@ -6806,16 +6940,6 @@ readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readdir-scoped-modules@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" - integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw== - dependencies: - debuglog "^1.0.1" - dezalgo "^1.0.0" - graceful-fs "^4.1.2" - once "^1.3.0" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -6857,7 +6981,7 @@ regexp.prototype.flags@^1.4.3: define-properties "^1.1.3" functions-have-names "^1.2.2" -regexpp@^3.0.0, regexpp@^3.2.0: +regexpp@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== @@ -7001,10 +7125,12 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.2.tgz#20dfbc98083bdfaa28b01183162885ef213dbf7c" - integrity sha512-BlIbgFryTbw3Dz6hyoWFhKk+unCcHMSkZGrTFVAx2WmttdBSonsdtRlwiuTbDqTKr+UlXIUqJVS4QT5tUzGENQ== +rimraf@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.1.tgz#bd33364f67021c5b79e93d7f4fa0568c7c21b755" + integrity sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og== + dependencies: + glob "^9.2.0" run-async@^2.4.0: version "2.4.1" @@ -7025,12 +7151,7 @@ rxjs@^7.5.5, rxjs@^7.8.0: dependencies: tslib "^2.1.0" -safe-buffer@^5.1.0, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@^5.1.0, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -7326,7 +7447,7 @@ ssri@9.0.1, ssri@^9.0.0: dependencies: minipass "^3.1.1" -ssri@^10.0.0: +ssri@^10.0.0, ssri@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.1.tgz#c61f85894bbc6929fc3746f05e31cf5b44c030d5" integrity sha512-WVy6di9DlPOeBWEjMScpNipeSX2jIZBGEn5Uuo8Q7aIuFEuDX0pw8RxcOjlD1TWP4obi24ki7m/13+nFpcbXrw== @@ -7383,14 +7504,7 @@ string.prototype.trimstart@^1.0.6: define-properties "^1.1.4" es-abstract "^1.20.4" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: +string_decoder@^1.1.1, string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== @@ -7530,7 +7644,7 @@ tar-stream@~2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@6.1.11: +tar@6.1.11, tar@^6.1.11, tar@^6.1.2: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== @@ -7542,23 +7656,27 @@ tar@6.1.11: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^6.1.11, tar@^6.1.2: - version "6.1.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.13.tgz#46e22529000f612180601a6fe0680e7da508847b" - integrity sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^4.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - temp-dir@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" integrity sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ== +temp-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" + integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== + +tempy@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tempy/-/tempy-1.0.0.tgz#4f192b3ee3328a2684d0e3fc5c491425395aab65" + integrity sha512-eLXG5B1G0mRPHmgH2WydPl5v4jH35qEn3y/rA/aahKhIa91Pn119SsU7n7v/433gtT9ONzC8ISvNHIh2JSTm0w== + dependencies: + del "^6.0.0" + is-stream "^2.0.0" + temp-dir "^2.0.0" + type-fest "^0.16.0" + unique-string "^2.0.0" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -7606,11 +7724,6 @@ tiny-glob@^0.2.9: globalyzer "0.1.0" globrex "^0.1.2" -tiny-typed-emitter@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" - integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== - tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -7642,10 +7755,10 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -treeverse@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-2.0.0.tgz#036dcef04bc3fd79a9b79a68d4da03e882d8a9ca" - integrity sha512-N5gJCkLu1aXccpOTtqV6ddSEi6ZmGkh3hjmbu1IjcavJK4qyOVQmi0myQKM7z5jVGmD68SJoliaVrMmVObhj6A== +treeverse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-3.0.0.tgz#dd82de9eb602115c6ebd77a574aae67003cb48c8" + integrity sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ== trim-newlines@^3.0.0: version "3.0.1" @@ -7734,6 +7847,11 @@ type-detect@^4.0.0, type-detect@^4.0.5: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" + integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== + type-fest@^0.18.0: version "0.18.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" @@ -7800,17 +7918,17 @@ typedoc-plugin-resolve-crossmodule-references@^0.3.3: resolved "https://registry.yarnpkg.com/typedoc-plugin-resolve-crossmodule-references/-/typedoc-plugin-resolve-crossmodule-references-0.3.3.tgz#812a6e7083f46031c69fe0021bb3913b10bd68cb" integrity sha512-ZWWBy2WR8z9a6iXYGlyB3KrpV+JDdZv1mndYU6Eh6mInrfMCrQJi3Y5K9ihMBfuaBGB//le1nEmQLgzU3IO+dw== -typedoc@^0.23.25: - version "0.23.26" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.26.tgz#ae082683698bad68757d8fe619242a56d6b5bf36" - integrity sha512-5m4KwR5tOLnk0OtMaRn9IdbeRM32uPemN9kur7YK9wFqx8U0CYrvO9aVq6ysdZSV1c824BTm+BuQl2Ze/k1HtA== +typedoc@^0.23.28: + version "0.23.28" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.28.tgz#3ce9c36ef1c273fa849d2dea18651855100d3ccd" + integrity sha512-9x1+hZWTHEQcGoP7qFmlo4unUoVJLB0H/8vfO/7wqTnZxg4kPuji9y3uRzEu0ZKez63OJAUmiGhUrtukC6Uj3w== dependencies: lunr "^2.3.9" marked "^4.2.12" minimatch "^7.1.3" shiki "^0.14.1" -"typescript@^3 || ^4", typescript@^4.6.4, typescript@^4.9.5: +"typescript@^3 || ^4", "typescript@^4.6.4 || ^5.0.0", typescript@^4.9.5: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== @@ -7878,6 +7996,13 @@ unique-slug@^4.0.0: dependencies: imurmurhash "^0.1.4" +unique-string@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" + integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== + dependencies: + crypto-random-string "^2.0.0" + unique-string@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-3.0.0.tgz#84a1c377aff5fd7a8bc6b55d8244b2bd90d75b9a" @@ -7905,7 +8030,7 @@ untildify@^4.0.0: resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== -upath@^2.0.1: +upath@2.0.1, upath@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== @@ -7945,7 +8070,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -8181,10 +8306,10 @@ write-file-atomic@^3.0.0, write-file-atomic@^3.0.3: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write-file-atomic@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== +write-file-atomic@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.0.tgz#54303f117e109bf3d540261125c8ea5a7320fab0" + integrity sha512-R7NYMnHSlV42K54lwY9lvW6MnSm1HSJqZL3xiSgi9E7//FYaI74r2G0rd+/X6VAMkHEdzxQaU5HUOXWUz5kA/w== dependencies: imurmurhash "^0.1.4" signal-exit "^3.0.7" @@ -8250,12 +8375,12 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yaml@^2.1.3, yaml@^2.2.1: +yaml@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== -yargs-parser@20.2.4: +yargs-parser@20.2.4, yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.4" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== @@ -8273,11 +8398,6 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.2, yargs-parser@^20.2.3: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-unparser@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb"