matterbridge-lg-thinq-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/.claude/CLAUDE.md +43 -0
  2. package/.claude/agents/compiler.md +91 -0
  3. package/.claude/agents/direct-executor.md +98 -0
  4. package/.claude/agents/documenter.md +78 -0
  5. package/.claude/agents/finalizer.md +233 -0
  6. package/.claude/agents/implementer.md +112 -0
  7. package/.claude/agents/investigator.md +100 -0
  8. package/.claude/agents/release-manager.md +203 -0
  9. package/.claude/agents/reviewer.md +131 -0
  10. package/.claude/agents/technical-architect.md +403 -0
  11. package/.claude/agents/test-writer.md +159 -0
  12. package/.claude/agents/wiki-manager.md +147 -0
  13. package/.claude/instructions/agent-prompts.md +90 -0
  14. package/.claude/instructions/shared-rules.md +11 -0
  15. package/.claude/instructions/team-orchestrator-policy.md +165 -0
  16. package/.claude/memory.md +186 -0
  17. package/.claude/output-styles/engineer-manager.md +38 -0
  18. package/.claude/settings.json +40 -0
  19. package/.claude/settings.local.json +30 -0
  20. package/.claude/skills/load-policy/SKILL.md +13 -0
  21. package/.claude/skills/ref-idea/SKILL.md +44 -0
  22. package/.claude/skills/status-of/SKILL.md +38 -0
  23. package/.claude/skills/workspace-console/SKILL.md +49 -0
  24. package/.claude/statusline-command.sh +130 -0
  25. package/.claude/statusline.md +53 -0
  26. package/.claude/templates/approval-packet.template.html +171 -0
  27. package/.claude/templates/reference-workspaces.md +16 -0
  28. package/.claude/tools/cli-runner-server.cjs +220 -0
  29. package/.claude/tools/discord-send-server.cjs +188 -0
  30. package/.claude/tools/github-release-server.cjs +181 -0
  31. package/.claude/tools/glob-grep-server.cjs +267 -0
  32. package/.claude/tools/read-log-server.cjs +175 -0
  33. package/.github/codeql/codeql-config.yml +14 -0
  34. package/.github/workflows/build.yml +54 -0
  35. package/.github/workflows/codeql.yml +59 -0
  36. package/.github/workflows/coverage.yml +70 -0
  37. package/.github/workflows/publish.yml +103 -0
  38. package/.mcp.json +34 -0
  39. package/CHANGELOG.md +195 -0
  40. package/LICENSE +21 -0
  41. package/dist/cli/commands/devices.js +52 -0
  42. package/dist/cli/commands/energy.js +122 -0
  43. package/dist/cli/commands/login.js +54 -0
  44. package/dist/cli/help.js +50 -0
  45. package/dist/cli/main.js +56 -0
  46. package/dist/cli/session.js +13 -0
  47. package/dist/cli/types.js +1 -0
  48. package/dist/cli/utils.js +29 -0
  49. package/dist/cli.js +2 -0
  50. package/dist/constants/index.js +1 -0
  51. package/dist/core/domain/entities/ThinqDevice.js +43 -0
  52. package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
  53. package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
  54. package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
  55. package/dist/errors/AuthenticationError.js +26 -0
  56. package/dist/errors/BaseError.js +23 -0
  57. package/dist/errors/index.js +2 -0
  58. package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
  59. package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
  60. package/dist/module.js +212 -0
  61. package/dist/platform/deviceRegistry.js +26 -0
  62. package/dist/platform/platformConfigManager.js +89 -0
  63. package/dist/platform/platformState.js +9 -0
  64. package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
  65. package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
  66. package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
  67. package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
  68. package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
  69. package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
  70. package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
  71. package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
  72. package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
  73. package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
  74. package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
  75. package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
  76. package/dist/services/authentication/AccountAuthStrategy.js +135 -0
  77. package/dist/services/authentication/AuthContext.js +1 -0
  78. package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
  79. package/dist/services/authentication/IAuthStrategy.js +1 -0
  80. package/dist/services/authentication/TokenAuthStrategy.js +27 -0
  81. package/dist/services/authentication/UserDataRepository.js +32 -0
  82. package/dist/services/thinq/gateway.js +61 -0
  83. package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
  84. package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
  85. package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
  86. package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
  87. package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
  88. package/dist/services/thinq/serviceContainer.js +73 -0
  89. package/dist/services/thinq/session.js +48 -0
  90. package/dist/services/thinq/thinqApiClient.js +258 -0
  91. package/dist/services/thinq/thinqDeviceService.js +75 -0
  92. package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
  93. package/dist/settings.js +9 -0
  94. package/dist/tests/helpers/testUtils.js +87 -0
  95. package/dist/utils/mask.js +39 -0
  96. package/docs/README_CLI.md +166 -0
  97. package/eslint.config.js +194 -0
  98. package/matterbridge-lg-thinq-plugin.config.json +27 -0
  99. package/matterbridge-lg-thinq-plugin.schema.json +520 -0
  100. package/package.json +107 -0
  101. package/prettier.config.js +49 -0
  102. package/scripts/parse-junit-failures.mjs +45 -0
  103. package/scripts/parse-tsc-failures.mjs +48 -0
  104. package/scripts/run-build-local-summary.mjs +29 -0
  105. package/scripts/run-format-summary.mjs +36 -0
  106. package/scripts/run-lint-fix-summary.mjs +38 -0
  107. package/scripts/run-precommit-summary.mjs +94 -0
  108. package/src/cli/commands/devices.ts +67 -0
  109. package/src/cli/commands/energy.ts +160 -0
  110. package/src/cli/commands/login.ts +74 -0
  111. package/src/cli/help.ts +53 -0
  112. package/src/cli/main.ts +66 -0
  113. package/src/cli/session.ts +15 -0
  114. package/src/cli/types.ts +10 -0
  115. package/src/cli/utils.ts +30 -0
  116. package/src/cli.ts +3 -0
  117. package/src/constants/index.ts +8 -0
  118. package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
  119. package/src/core/domain/entities/ThinqDevice.ts +74 -0
  120. package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
  121. package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
  122. package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
  123. package/src/errors/AuthenticationError.ts +37 -0
  124. package/src/errors/BaseError.ts +38 -0
  125. package/src/errors/index.ts +24 -0
  126. package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
  127. package/src/module.ts +298 -0
  128. package/src/platform/deviceRegistry.ts +58 -0
  129. package/src/platform/platformConfigManager.ts +133 -0
  130. package/src/platform/platformState.ts +16 -0
  131. package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
  132. package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
  133. package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
  134. package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
  135. package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
  136. package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
  137. package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
  138. package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
  139. package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
  140. package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
  141. package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
  142. package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
  143. package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
  144. package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
  145. package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
  146. package/src/services/authentication/AccountAuthStrategy.ts +209 -0
  147. package/src/services/authentication/AuthContext.ts +19 -0
  148. package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
  149. package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
  150. package/src/services/authentication/IAuthStrategy.ts +10 -0
  151. package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
  152. package/src/services/authentication/TokenAuthStrategy.ts +40 -0
  153. package/src/services/authentication/UserDataRepository.test.ts +149 -0
  154. package/src/services/authentication/UserDataRepository.ts +46 -0
  155. package/src/services/thinq/gateway.ts +86 -0
  156. package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
  157. package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
  158. package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
  159. package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
  160. package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
  161. package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
  162. package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
  163. package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
  164. package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
  165. package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
  166. package/src/services/thinq/serviceContainer.test.ts +203 -0
  167. package/src/services/thinq/serviceContainer.ts +105 -0
  168. package/src/services/thinq/session.test.ts +287 -0
  169. package/src/services/thinq/session.ts +69 -0
  170. package/src/services/thinq/thinqApiClient.test.ts +702 -0
  171. package/src/services/thinq/thinqApiClient.ts +418 -0
  172. package/src/services/thinq/thinqDeviceService.test.ts +415 -0
  173. package/src/services/thinq/thinqDeviceService.ts +101 -0
  174. package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
  175. package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
  176. package/src/settings.ts +18 -0
  177. package/src/tests/cli/commands/devices.test.ts +293 -0
  178. package/src/tests/cli/commands/login.test.ts +240 -0
  179. package/src/tests/cli/help.test.ts +46 -0
  180. package/src/tests/cli/main.test.ts +214 -0
  181. package/src/tests/cli/session.test.ts +198 -0
  182. package/src/tests/cli/utils.test.ts +194 -0
  183. package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
  184. package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
  185. package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
  186. package/src/tests/helpers/testUtils.ts +118 -0
  187. package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
  188. package/src/tests/platform/deviceRegistry.test.ts +177 -0
  189. package/src/tests/platform/platformConfigManager.test.ts +790 -0
  190. package/src/tests/platform/platformState.test.ts +41 -0
  191. package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
  192. package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
  193. package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
  194. package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
  195. package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
  196. package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
  197. package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
  198. package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
  199. package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
  200. package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
  201. package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
  202. package/src/tests/settings.test.ts +33 -0
  203. package/src/utils/mask.ts +50 -0
  204. package/tsconfig.base.json +25 -0
  205. package/tsconfig.json +9 -0
  206. package/tsconfig.production.json +13 -0
  207. package/tsconfig.vitest.json +5 -0
  208. package/vite.config.ts +43 -0
  209. package/wiki/memory-archive.md +12 -0
  210. package/wiki/reference-workspaces.md +23 -0
@@ -0,0 +1,373 @@
1
+ import { OnOff, OperationalState } from 'matterbridge/matter/clusters';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { ThinqSnapshot } from '../../../core/domain/value-objects/ThinqSnapshot.js';
5
+ import {
6
+ applyThinqSnapshotToWasher,
7
+ mapWasherStateToOperationalState,
8
+ } from '../../../platform/thinq/thinqWasherStateSync.js';
9
+ import { asPartial, createMockLogger } from '../../helpers/testUtils.js';
10
+
11
+ function createMockWasherEndpoint(): any {
12
+ return asPartial<any>({
13
+ serialNumber: 'washer-123',
14
+ uniqueId: undefined,
15
+ log: createMockLogger(),
16
+ updateAttribute: vi.fn().mockResolvedValue(false),
17
+ getAttribute: vi.fn().mockReturnValue(OperationalState.OperationalStateEnum.Stopped),
18
+ });
19
+ }
20
+
21
+ describe('thinqWasherStateSync', () => {
22
+ describe('mapWasherStateToOperationalState', () => {
23
+ it('should return Error when isWasherError is true', () => {
24
+ // Arrange
25
+ const snapshot = new ThinqSnapshot({
26
+ washerDryer: { state: 'ERROR' },
27
+ });
28
+
29
+ // Act
30
+ const result = mapWasherStateToOperationalState(snapshot);
31
+
32
+ // Assert
33
+ expect(result).toBe(OperationalState.OperationalStateEnum.Error);
34
+ });
35
+
36
+ it('should return Running when isWasherRunning is true', () => {
37
+ // Arrange
38
+ const snapshot = new ThinqSnapshot({
39
+ washerDryer: { state: 'RUNNING' },
40
+ });
41
+
42
+ // Act
43
+ const result = mapWasherStateToOperationalState(snapshot);
44
+
45
+ // Assert
46
+ expect(result).toBe(OperationalState.OperationalStateEnum.Running);
47
+ });
48
+
49
+ it('should return Paused when washerRawState is PAUSE', () => {
50
+ // Arrange
51
+ const snapshot = new ThinqSnapshot({
52
+ washerDryer: { state: 'PAUSE' },
53
+ });
54
+
55
+ // Act
56
+ const result = mapWasherStateToOperationalState(snapshot);
57
+
58
+ // Assert
59
+ expect(result).toBe(OperationalState.OperationalStateEnum.Paused);
60
+ });
61
+
62
+ it('should return Stopped when powered off (POWEROFF)', () => {
63
+ // Arrange
64
+ const snapshot = new ThinqSnapshot({
65
+ washerDryer: { state: 'POWEROFF' },
66
+ });
67
+
68
+ // Act
69
+ const result = mapWasherStateToOperationalState(snapshot);
70
+
71
+ // Assert
72
+ expect(result).toBe(OperationalState.OperationalStateEnum.Stopped);
73
+ });
74
+
75
+ it('should return Stopped for END state', () => {
76
+ // Arrange
77
+ const snapshot = new ThinqSnapshot({
78
+ washerDryer: { state: 'END' },
79
+ });
80
+
81
+ // Act
82
+ const result = mapWasherStateToOperationalState(snapshot);
83
+
84
+ // Assert
85
+ expect(result).toBe(OperationalState.OperationalStateEnum.Stopped);
86
+ });
87
+
88
+ it('should return Running for unknown state (not in NOT_RUNNING_STATES)', () => {
89
+ // Arrange
90
+ const snapshot = new ThinqSnapshot({
91
+ washerDryer: { state: 'UNKNOWN_STATE' },
92
+ });
93
+
94
+ // Act
95
+ const result = mapWasherStateToOperationalState(snapshot);
96
+
97
+ // Assert
98
+ // Unknown states not in WASHER_NOT_RUNNING_STATES are treated as running
99
+ expect(result).toBe(OperationalState.OperationalStateEnum.Running);
100
+ });
101
+
102
+ it('should return Stopped when washerDryer.state is absent', () => {
103
+ // Arrange
104
+ const snapshot = new ThinqSnapshot({});
105
+
106
+ // Act
107
+ const result = mapWasherStateToOperationalState(snapshot);
108
+
109
+ // Assert
110
+ expect(result).toBe(OperationalState.OperationalStateEnum.Stopped);
111
+ });
112
+
113
+ it('should prioritize error over running state', () => {
114
+ // Arrange (ERROR state takes priority over running logic)
115
+ const snapshot = new ThinqSnapshot({
116
+ washerDryer: { state: 'ERROR' },
117
+ });
118
+
119
+ // Act
120
+ const result = mapWasherStateToOperationalState(snapshot);
121
+
122
+ // Assert
123
+ expect(result).toBe(OperationalState.OperationalStateEnum.Error);
124
+ });
125
+ });
126
+
127
+ describe('applyThinqSnapshotToWasher', () => {
128
+ let mockLogger: ReturnType<typeof createMockLogger>;
129
+ let mockWasher: any;
130
+
131
+ beforeEach(() => {
132
+ vi.clearAllMocks();
133
+ mockLogger = createMockLogger();
134
+ mockWasher = createMockWasherEndpoint();
135
+ });
136
+
137
+ it('should skip update when washerDryer.state is absent', async () => {
138
+ // Arrange
139
+ const snapshot = new ThinqSnapshot({
140
+ washerDryer: { remainTimeHour: 1 },
141
+ });
142
+
143
+ // Act
144
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
145
+
146
+ // Assert
147
+ expect(mockWasher.updateAttribute).not.toHaveBeenCalled();
148
+ expect(mockLogger.debug).toHaveBeenCalledWith(
149
+ expect.stringContaining('skipped (source key absent): washerDryer.state'),
150
+ );
151
+ });
152
+
153
+ it('should update onOff, operationalState, and operationalError when state is present and running', async () => {
154
+ // Arrange
155
+ const snapshot = new ThinqSnapshot({
156
+ washerDryer: { state: 'RUNNING' },
157
+ });
158
+
159
+ // Act
160
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
161
+
162
+ // Assert
163
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(OnOff.id, 'onOff', true, mockLogger);
164
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(
165
+ OperationalState.id,
166
+ 'operationalState',
167
+ OperationalState.OperationalStateEnum.Running,
168
+ mockLogger,
169
+ );
170
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(
171
+ OperationalState.id,
172
+ 'operationalError',
173
+ { errorStateId: OperationalState.ErrorState.NoError },
174
+ mockLogger,
175
+ );
176
+ });
177
+
178
+ it('should set onOff to false when powered off (POWEROFF)', async () => {
179
+ // Arrange
180
+ const snapshot = new ThinqSnapshot({
181
+ washerDryer: { state: 'POWEROFF' },
182
+ });
183
+
184
+ // Act
185
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
186
+
187
+ // Assert
188
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(OnOff.id, 'onOff', false, mockLogger);
189
+ });
190
+
191
+ it('should set operationalState to Stopped when powered off', async () => {
192
+ // Arrange
193
+ const snapshot = new ThinqSnapshot({
194
+ washerDryer: { state: 'POWEROFF' },
195
+ });
196
+
197
+ // Act
198
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
199
+
200
+ // Assert
201
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(
202
+ OperationalState.id,
203
+ 'operationalState',
204
+ OperationalState.OperationalStateEnum.Stopped,
205
+ mockLogger,
206
+ );
207
+ });
208
+
209
+ it('should set operationalError to UnableToCompleteOperation when ERROR state', async () => {
210
+ // Arrange
211
+ const snapshot = new ThinqSnapshot({
212
+ washerDryer: { state: 'ERROR' },
213
+ });
214
+
215
+ // Act
216
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
217
+
218
+ // Assert
219
+ expect(mockWasher.updateAttribute).toHaveBeenCalledWith(
220
+ OperationalState.id,
221
+ 'operationalError',
222
+ { errorStateId: OperationalState.ErrorState.UnableToCompleteOperation },
223
+ mockLogger,
224
+ );
225
+ });
226
+
227
+ it('should update countdownTime when both remainTimeHour and remainTimeMinute are present', async () => {
228
+ // Arrange
229
+ const snapshot = new ThinqSnapshot({
230
+ washerDryer: { state: 'RUNNING', remainTimeHour: 1, remainTimeMinute: 30 },
231
+ });
232
+
233
+ // Act
234
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
235
+
236
+ // Assert
237
+ const calls = vi.mocked(mockWasher.updateAttribute).mock.calls;
238
+ const countdownCall = calls.find((call: any[]) => call[0] === OperationalState.id && call[1] === 'countdownTime');
239
+ expect(countdownCall).toBeDefined();
240
+ expect(countdownCall?.at(2)).toBe(5400); // 1 hour (3600s) + 30 minutes (1800s)
241
+ });
242
+
243
+ it('should update countdownTime when only remainTimeHour is present', async () => {
244
+ // Arrange
245
+ const snapshot = new ThinqSnapshot({
246
+ washerDryer: { state: 'RUNNING', remainTimeHour: 2 },
247
+ });
248
+
249
+ // Act
250
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
251
+
252
+ // Assert
253
+ const calls = vi.mocked(mockWasher.updateAttribute).mock.calls;
254
+ const countdownCall = calls.find((call: any[]) => call[0] === OperationalState.id && call[1] === 'countdownTime');
255
+ expect(countdownCall).toBeDefined();
256
+ expect(countdownCall?.at(2)).toBe(7200); // 2 hours
257
+ });
258
+
259
+ it('should update countdownTime when only remainTimeMinute is present', async () => {
260
+ // Arrange
261
+ const snapshot = new ThinqSnapshot({
262
+ washerDryer: { state: 'RUNNING', remainTimeMinute: 45 },
263
+ });
264
+
265
+ // Act
266
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
267
+
268
+ // Assert
269
+ const calls = vi.mocked(mockWasher.updateAttribute).mock.calls;
270
+ const countdownCall = calls.find((call: any[]) => call[0] === OperationalState.id && call[1] === 'countdownTime');
271
+ expect(countdownCall).toBeDefined();
272
+ expect(countdownCall?.at(2)).toBe(2700); // 45 minutes
273
+ });
274
+
275
+ it('should NOT update countdownTime when remain time keys are absent', async () => {
276
+ // Arrange
277
+ const snapshot = new ThinqSnapshot({
278
+ washerDryer: { state: 'RUNNING' },
279
+ });
280
+
281
+ // Act
282
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
283
+
284
+ // Assert
285
+ const calls = vi.mocked(mockWasher.updateAttribute).mock.calls;
286
+ const countdownCall = calls.find((call: any[]) => call[0] === OperationalState.id && call[1] === 'countdownTime');
287
+ expect(countdownCall).toBeUndefined();
288
+ });
289
+
290
+ it('should set countdownTime to 0 when washer not running but remain time keys present', async () => {
291
+ // Arrange
292
+ const snapshot = new ThinqSnapshot({
293
+ washerDryer: { state: 'END', remainTimeHour: 1 },
294
+ });
295
+
296
+ // Act
297
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
298
+
299
+ // Assert
300
+ const calls = vi.mocked(mockWasher.updateAttribute).mock.calls;
301
+ const countdownCall = calls.find((call: any[]) => call[0] === OperationalState.id && call[1] === 'countdownTime');
302
+ expect(countdownCall).toBeDefined();
303
+ expect(countdownCall?.at(2)).toBe(0); // Zeroed when not running
304
+ });
305
+
306
+ it('should update attribute when updateAttribute succeeds', async () => {
307
+ // Arrange
308
+ const snapshot = new ThinqSnapshot({
309
+ washerDryer: { state: 'RUNNING' },
310
+ });
311
+ mockWasher.updateAttribute.mockResolvedValue(false);
312
+
313
+ // Act
314
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
315
+
316
+ // Assert
317
+ expect(mockWasher.updateAttribute).toHaveBeenCalled();
318
+ });
319
+
320
+ it('should use serialNumber when available for logging', async () => {
321
+ // Arrange
322
+ const snapshot = new ThinqSnapshot({
323
+ washerDryer: { state: 'RUNNING' },
324
+ });
325
+ mockWasher.serialNumber = 'serial-123';
326
+
327
+ // Act
328
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
329
+
330
+ // Assert
331
+ const debugCalls = vi.mocked(mockLogger.debug).mock.calls;
332
+ const entryLog = debugCalls.find((call: any[]) => call[0]?.includes('entry for deviceId'));
333
+ expect(entryLog).toBeDefined();
334
+ expect(entryLog?.at(0)).toContain('serial-123');
335
+ });
336
+
337
+ it('should fall back to uniqueId when serialNumber is not available', async () => {
338
+ // Arrange
339
+ const snapshot = new ThinqSnapshot({
340
+ washerDryer: { state: 'RUNNING' },
341
+ });
342
+ mockWasher.serialNumber = undefined;
343
+ mockWasher.uniqueId = 'unique-456';
344
+
345
+ // Act
346
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
347
+
348
+ // Assert
349
+ const debugCalls = vi.mocked(mockLogger.debug).mock.calls;
350
+ const entryLog = debugCalls.find((call: any[]) => call[0]?.includes('entry for deviceId'));
351
+ expect(entryLog).toBeDefined();
352
+ expect(entryLog?.at(0)).toContain('unique-456');
353
+ });
354
+
355
+ it('should use "unknown" when neither serialNumber nor uniqueId available', async () => {
356
+ // Arrange
357
+ const snapshot = new ThinqSnapshot({
358
+ washerDryer: { state: 'RUNNING' },
359
+ });
360
+ mockWasher.serialNumber = undefined;
361
+ mockWasher.uniqueId = undefined;
362
+
363
+ // Act
364
+ await applyThinqSnapshotToWasher(mockWasher, snapshot, mockLogger);
365
+
366
+ // Assert
367
+ const debugCalls = vi.mocked(mockLogger.debug).mock.calls;
368
+ const entryLog = debugCalls.find((call: any[]) => call[0]?.includes('entry for deviceId'));
369
+ expect(entryLog).toBeDefined();
370
+ expect(entryLog?.at(0)).toContain('unknown');
371
+ });
372
+ });
373
+ });