From 829acd4df0b76ca8306af2b7010c86f0afa78fad Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 18:34:12 +0200 Subject: [PATCH 1/3] Make control surfaces reflect server truth (no panel-local optimism) Per the control-surface-state-pattern decision (Option A): - Switch panel (Rule 1): drop the local @State isOn mirror + onChange reconcile; the power button now binds directly to viewModel.isOn, so a failed service call can no longer leave it stuck disagreeing with the card. - CommitCooldown (Rule 2): replace the pure time-computed isSuppressed with a stored, observed flag driven by a cancellable expiry timer. Window expiry is now observable, so after a failed light/climate slider commit the VM re-reads the model and onChange reverts the slider (~1s). Re-commits debounce by cancelling the prior timer. Duration stays injectable for tests. - Cover: adopt the shared CommitCooldown with a pendingPosition so its position slider reconciles on failure like Light/Climate. Tests: new CommitCooldownTests (immediate suppress, expiry, recommit debounce) plus VM-level tests that brightness/targetTemp/position return the pending value while suppressed and revert to the model after expiry. Co-Authored-By: Claude Opus 4.8 --- Hemera/Entities/CommitCooldown.swift | 29 ++++++---- .../Cover/UI/CoverCardViewModel.swift | 13 ++++- .../Switch/UI/SwitchControlPanel.swift | 16 ++---- .../Entities/ClimateCardViewModelTests.swift | 27 +++++++++ .../Entities/CommitCooldownTests.swift | 55 +++++++++++++++++++ .../Entities/CoverCardViewModelTests.swift | 26 +++++++++ .../Entities/LightCardViewModelTests.swift | 20 +++++++ 7 files changed, 161 insertions(+), 25 deletions(-) create mode 100644 HemeraTests/Entities/CommitCooldownTests.swift diff --git a/Hemera/Entities/CommitCooldown.swift b/Hemera/Entities/CommitCooldown.swift index 5dc1385..22545c2 100644 --- a/Hemera/Entities/CommitCooldown.swift +++ b/Hemera/Entities/CommitCooldown.swift @@ -21,21 +21,28 @@ import Foundation @Observable @MainActor final class CommitCooldown { - private static let duration: TimeInterval = 1 + /// Stored & observed so window expiry notifies observers (a pure time-computed + /// value would flip silently, leaving sliders stuck at the dragged position on + /// a failed commit — no `state_changed` arrives to trigger reconciliation). + private(set) var isSuppressed = false - private var lastCommitDate: Date? - private let now: @Sendable () -> Date + private let duration: TimeInterval + private var expiryTask: Task? - init(now: @escaping @Sendable () -> Date = { Date() }) { - self.now = now - } - - var isSuppressed: Bool { - guard let last = lastCommitDate else { return false } - return now().timeIntervalSince(last) < Self.duration + init(duration: TimeInterval = 1) { + self.duration = duration } func commit() { - lastCommitDate = now() + isSuppressed = true + // Cancel-on-recommit debounces rapid drags: the window always measures + // from the last commit. + expiryTask?.cancel() + expiryTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(self.duration)) + guard !Task.isCancelled else { return } + self.isSuppressed = false + } } } diff --git a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift index d9f565d..e4d025f 100644 --- a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift +++ b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift @@ -24,9 +24,14 @@ final class CoverCardViewModel: Identifiable { var name: String { cover.name } var isAvailable: Bool { cover.isAvailable } var deviceId: String? { cover.deviceId } - var position: Int? { cover.currentPosition } + var position: Int? { + if cooldown.isSuppressed, let pending = pendingPosition { return pending } + return cover.currentPosition + } var state: CoverEntity.State { cover.state } + private var pendingPosition: Int? + private let cooldown: CommitCooldown private let controller: CoverControlling private(set) var actionTask: Task? @@ -109,7 +114,9 @@ final class CoverCardViewModel: Identifiable { } init(cover: CoverEntity, - controller: CoverControlling) { + controller: CoverControlling, + cooldown: CommitCooldown? = nil) { + self.cooldown = cooldown ?? CommitCooldown() self.id = cover.entityId self.cover = cover self.controller = controller @@ -119,6 +126,8 @@ final class CoverCardViewModel: Identifiable { func setPosition(to position: Int) { guard cover.isAvailable else { return } + pendingPosition = position + cooldown.commit() actionTask = Task { await controller.setPosition(of: id, to: position) } diff --git a/Hemera/Entities/Switch/UI/SwitchControlPanel.swift b/Hemera/Entities/Switch/UI/SwitchControlPanel.swift index 530a660..ea3767d 100644 --- a/Hemera/Entities/Switch/UI/SwitchControlPanel.swift +++ b/Hemera/Entities/Switch/UI/SwitchControlPanel.swift @@ -5,9 +5,11 @@ struct SwitchControlPanel: View { var viewModel: SwitchCardViewModel @Binding var isPresented: Bool - @State private var isOn: Bool = false @State private var isPressed: Bool = false + // Reflects the model (server truth) — no local optimistic mirror. + private var isOn: Bool { viewModel.isOn } + var body: some View { EntityControlPanel( isPresented: $isPresented, @@ -16,12 +18,6 @@ struct SwitchControlPanel: View { ) { powerButton } - .onAppear { isOn = viewModel.isOn } - .onChange(of: viewModel.isOn) { _, newValue in - withAnimation(Mortar.Motion.springBouncy) { - isOn = newValue - } - } } // MARK: - Subtitle @@ -66,11 +62,7 @@ struct SwitchControlPanel: View { private func toggleState() { UIImpactFeedbackGenerator(style: .medium).impactOccurred() - let newState = !isOn - withAnimation(Mortar.Motion.springBouncy) { - isOn = newState - } - viewModel.setOn(newState) + viewModel.setOn(!viewModel.isOn) } } diff --git a/HemeraTests/Entities/ClimateCardViewModelTests.swift b/HemeraTests/Entities/ClimateCardViewModelTests.swift index 5e9c71b..3f205cd 100644 --- a/HemeraTests/Entities/ClimateCardViewModelTests.swift +++ b/HemeraTests/Entities/ClimateCardViewModelTests.swift @@ -294,6 +294,33 @@ struct ClimateCardViewModelTests { #expect(vm.availableHVACModes == [.off, .heat]) } + // MARK: - Target Temperature Cooldown + + @Test + func targetTemperature_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { + let cooldown = CommitCooldown(duration: 0.1) + let climate = ClimateEntity( + entityId: "climate.test", + name: "Test Climate", + state: .heat, + temperature: 20, + hvacModesRaw: ["off", "heat"], + supportedFeaturesRaw: ClimateEntity.SupportedFeatures.targetTemperature.rawValue + ) + let vm = ClimateCardViewModel(climate: climate, controller: SpyClimateControlling(), cooldown: cooldown) + + #expect(vm.targetTemperature == 20) + + // Commit a new target; the model is unchanged (server has not confirmed). + vm.setTemperature(24) + #expect(vm.targetTemperature == 24) + + // No state_changed arrives (failed commit): after the window the value + // must reconcile back to the model (server truth). + try await Task.sleep(for: .milliseconds(250)) + #expect(vm.targetTemperature == 20) + } + // MARK: - Helpers private func makeViewModel( diff --git a/HemeraTests/Entities/CommitCooldownTests.swift b/HemeraTests/Entities/CommitCooldownTests.swift new file mode 100644 index 0000000..53b6e21 --- /dev/null +++ b/HemeraTests/Entities/CommitCooldownTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import Hemera + +@MainActor +struct CommitCooldownTests { + + // MARK: - Initial State + + @Test + func isSuppressed_initially_isFalse() { + let cooldown = CommitCooldown(duration: 0.2) + #expect(cooldown.isSuppressed == false) + } + + // MARK: - Commit + + @Test + func commit_suppressesImmediately() { + let cooldown = CommitCooldown(duration: 0.2) + cooldown.commit() + #expect(cooldown.isSuppressed == true) + } + + @Test + func commit_expiresAfterDuration() async throws { + let cooldown = CommitCooldown(duration: 0.1) + cooldown.commit() + #expect(cooldown.isSuppressed == true) + + try await Task.sleep(for: .milliseconds(250)) + #expect(cooldown.isSuppressed == false) + } + + // MARK: - Debounce + + @Test + func commit_recommit_measuresWindowFromLastCommit() async throws { + let cooldown = CommitCooldown(duration: 0.2) + cooldown.commit() + + // Re-commit before the first window expires; the window must now be + // measured from this second commit, not the first. + try await Task.sleep(for: .milliseconds(120)) + cooldown.commit() + + // Past the original 200ms window but within the extended one. + try await Task.sleep(for: .milliseconds(120)) + #expect(cooldown.isSuppressed == true) + + // Past the extended window. + try await Task.sleep(for: .milliseconds(250)) + #expect(cooldown.isSuppressed == false) + } +} diff --git a/HemeraTests/Entities/CoverCardViewModelTests.swift b/HemeraTests/Entities/CoverCardViewModelTests.swift index 9f62978..cd873bd 100644 --- a/HemeraTests/Entities/CoverCardViewModelTests.swift +++ b/HemeraTests/Entities/CoverCardViewModelTests.swift @@ -221,6 +221,32 @@ struct CoverCardViewModelTests { #expect(vm.tintColor == .blue) } + // MARK: - Position Cooldown + + @Test + func position_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { + let cooldown = CommitCooldown(duration: 0.1) + let cover = CoverEntity( + entityId: "cover.test", + name: "Test Cover", + state: .open, + currentPosition: 30, + supportedFeaturesRaw: CoverEntity.Features.setPosition.rawValue + ) + let vm = CoverCardViewModel(cover: cover, controller: SpyCoverControlling(), cooldown: cooldown) + + #expect(vm.position == 30) + + // Commit a new position; the model is unchanged (server has not confirmed). + vm.setPosition(to: 80) + #expect(vm.position == 80) + + // No state_changed arrives (failed commit): after the window the slider + // value must reconcile back to the model (server truth). + try await Task.sleep(for: .milliseconds(250)) + #expect(vm.position == 30) + } + // MARK: - Helpers private func makeViewModel( diff --git a/HemeraTests/Entities/LightCardViewModelTests.swift b/HemeraTests/Entities/LightCardViewModelTests.swift index 63b052b..b44a309 100644 --- a/HemeraTests/Entities/LightCardViewModelTests.swift +++ b/HemeraTests/Entities/LightCardViewModelTests.swift @@ -43,6 +43,26 @@ struct LightCardViewModelTests { #expect(vm.iconName == "lightbulb.fill") } + // MARK: - Brightness Cooldown + + @Test + func brightness_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { + let cooldown = CommitCooldown(duration: 0.1) + let light = LightEntity(entityId: "light.test", name: "Test", state: .on, brightness: 100) + let vm = LightCardViewModel(light: light, controller: StubLightControlling(), cooldown: cooldown) + + #expect(vm.brightness == 100) + + // Commit a new value; the model is unchanged (server has not confirmed). + vm.setBrightness(to: 200) + #expect(vm.brightness == 200) + + // No state_changed arrives (failed commit): after the window the slider + // value must reconcile back to the model (server truth). + try await Task.sleep(for: .milliseconds(250)) + #expect(vm.brightness == 100) + } + // MARK: - Helpers private func makeViewModel(state: LightEntity.State) -> LightCardViewModel { From 02f60a2a40a8db3f94b14b9226702f05386493cc Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 21:42:06 +0200 Subject: [PATCH 2/3] Use /** */ block form for multi-line comments Convert the multi-line comments introduced in the previous commit to the project's /** ... */ block convention. Single-line // comments untouched. Co-Authored-By: Claude Opus 4.8 --- Hemera/Entities/CommitCooldown.swift | 14 +++++++++----- .../Entities/ClimateCardViewModelTests.swift | 6 ++++-- HemeraTests/Entities/CommitCooldownTests.swift | 6 ++++-- HemeraTests/Entities/CoverCardViewModelTests.swift | 6 ++++-- HemeraTests/Entities/LightCardViewModelTests.swift | 6 ++++-- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Hemera/Entities/CommitCooldown.swift b/Hemera/Entities/CommitCooldown.swift index 22545c2..9d82ba7 100644 --- a/Hemera/Entities/CommitCooldown.swift +++ b/Hemera/Entities/CommitCooldown.swift @@ -21,9 +21,11 @@ import Foundation @Observable @MainActor final class CommitCooldown { - /// Stored & observed so window expiry notifies observers (a pure time-computed - /// value would flip silently, leaving sliders stuck at the dragged position on - /// a failed commit — no `state_changed` arrives to trigger reconciliation). + /** + Stored & observed so window expiry notifies observers (a pure time-computed + value would flip silently, leaving sliders stuck at the dragged position on + a failed commit — no `state_changed` arrives to trigger reconciliation). + */ private(set) var isSuppressed = false private let duration: TimeInterval @@ -35,8 +37,10 @@ final class CommitCooldown { func commit() { isSuppressed = true - // Cancel-on-recommit debounces rapid drags: the window always measures - // from the last commit. + /** + Cancel-on-recommit debounces rapid drags: the window always measures + from the last commit. + */ expiryTask?.cancel() expiryTask = Task { [weak self] in guard let self else { return } diff --git a/HemeraTests/Entities/ClimateCardViewModelTests.swift b/HemeraTests/Entities/ClimateCardViewModelTests.swift index 3f205cd..ea591ed 100644 --- a/HemeraTests/Entities/ClimateCardViewModelTests.swift +++ b/HemeraTests/Entities/ClimateCardViewModelTests.swift @@ -315,8 +315,10 @@ struct ClimateCardViewModelTests { vm.setTemperature(24) #expect(vm.targetTemperature == 24) - // No state_changed arrives (failed commit): after the window the value - // must reconcile back to the model (server truth). + /** + No state_changed arrives (failed commit): after the window the value + must reconcile back to the model (server truth). + */ try await Task.sleep(for: .milliseconds(250)) #expect(vm.targetTemperature == 20) } diff --git a/HemeraTests/Entities/CommitCooldownTests.swift b/HemeraTests/Entities/CommitCooldownTests.swift index 53b6e21..e5bbe84 100644 --- a/HemeraTests/Entities/CommitCooldownTests.swift +++ b/HemeraTests/Entities/CommitCooldownTests.swift @@ -39,8 +39,10 @@ struct CommitCooldownTests { let cooldown = CommitCooldown(duration: 0.2) cooldown.commit() - // Re-commit before the first window expires; the window must now be - // measured from this second commit, not the first. + /** + Re-commit before the first window expires; the window must now be + measured from this second commit, not the first. + */ try await Task.sleep(for: .milliseconds(120)) cooldown.commit() diff --git a/HemeraTests/Entities/CoverCardViewModelTests.swift b/HemeraTests/Entities/CoverCardViewModelTests.swift index cd873bd..71fee8d 100644 --- a/HemeraTests/Entities/CoverCardViewModelTests.swift +++ b/HemeraTests/Entities/CoverCardViewModelTests.swift @@ -241,8 +241,10 @@ struct CoverCardViewModelTests { vm.setPosition(to: 80) #expect(vm.position == 80) - // No state_changed arrives (failed commit): after the window the slider - // value must reconcile back to the model (server truth). + /** + No state_changed arrives (failed commit): after the window the slider + value must reconcile back to the model (server truth). + */ try await Task.sleep(for: .milliseconds(250)) #expect(vm.position == 30) } diff --git a/HemeraTests/Entities/LightCardViewModelTests.swift b/HemeraTests/Entities/LightCardViewModelTests.swift index b44a309..a6607da 100644 --- a/HemeraTests/Entities/LightCardViewModelTests.swift +++ b/HemeraTests/Entities/LightCardViewModelTests.swift @@ -57,8 +57,10 @@ struct LightCardViewModelTests { vm.setBrightness(to: 200) #expect(vm.brightness == 200) - // No state_changed arrives (failed commit): after the window the slider - // value must reconcile back to the model (server truth). + /** + No state_changed arrives (failed commit): after the window the slider + value must reconcile back to the model (server truth). + */ try await Task.sleep(for: .milliseconds(250)) #expect(vm.brightness == 100) } From e2d6162e441ec6c911a21622fb2e4bfdca251e95 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 22:24:08 +0200 Subject: [PATCH 3/3] Address code-review: server-truth cover position + no stale pending Two fixes from the review of the control-surface-server-truth change: - Cover (finding 2): position reverted to server truth (cover.currentPosition) so the card tile, subtitle, and iconTapped never reflect an unconfirmed value. The slider's optimistic-with-failure-reconcile behaviour moves to a dedicated pending-aware sliderPosition, which CoverControlPanel now observes. - Light/Climate (finding 1): one CommitCooldown gates several pending values, so each commit now clears the others first (resetPending). Otherwise a stale pending from an earlier commit resurfaced and masked server truth when a different property was committed within the shared window. Tests: cover asserts position stays server truth during a pending commit and sliderPosition reconciles on expiry; a new light test locks in the no-stale- pending behaviour. Timing-dependent tests now await CommitCooldown.expiryTask instead of racing a fixed sleep against the scheduler (removes flakiness). Co-Authored-By: Claude Opus 4.8 --- .../Climate/UI/ClimateCardViewModel.swift | 14 +++++++++ Hemera/Entities/CommitCooldown.swift | 5 ++- .../Cover/UI/CoverCardViewModel.swift | 12 +++++-- .../Entities/Cover/UI/CoverControlPanel.swift | 4 +-- .../Light/UI/LightCardViewModel.swift | 15 +++++++++ .../Entities/ClimateCardViewModelTests.swift | 9 +++--- .../Entities/CommitCooldownTests.swift | 27 ++++++++-------- .../Entities/CoverCardViewModelTests.swift | 31 +++++++++++++++---- .../Entities/LightCardViewModelTests.swift | 27 +++++++++++++--- 9 files changed, 112 insertions(+), 32 deletions(-) diff --git a/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift b/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift index f049474..5322d93 100644 --- a/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift +++ b/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift @@ -214,6 +214,7 @@ final class ClimateCardViewModel: Identifiable { func setTemperature(_ temperature: Double) { guard climate.isAvailable else { return } + resetPending() pendingTargetTemp = temperature cooldown.commit() actionTask = Task { await controller.setTemperature(id, temperature: temperature) } @@ -221,12 +222,25 @@ final class ClimateCardViewModel: Identifiable { func setTemperatureRange(low: Double, high: Double) { guard climate.isAvailable else { return } + resetPending() pendingTargetTempLow = low pendingTargetTempHigh = high cooldown.commit() actionTask = Task { await controller.setTemperatureRange(id, low: low, high: high) } } + /** + One cooldown gates the single-setpoint and range pending values, so every + commit clears the others first — otherwise a stale pending from an earlier + commit would resurface (and mask server truth) when a different setpoint is + committed within the shared window. + */ + private func resetPending() { + pendingTargetTemp = nil + pendingTargetTempLow = nil + pendingTargetTempHigh = nil + } + func setFanMode(_ mode: String) { guard climate.isAvailable else { return } actionTask = Task { await controller.setFanMode(id, mode: mode) } diff --git a/Hemera/Entities/CommitCooldown.swift b/Hemera/Entities/CommitCooldown.swift index 9d82ba7..0c9543a 100644 --- a/Hemera/Entities/CommitCooldown.swift +++ b/Hemera/Entities/CommitCooldown.swift @@ -29,7 +29,10 @@ final class CommitCooldown { private(set) var isSuppressed = false private let duration: TimeInterval - private var expiryTask: Task? + + /// Exposed read-only so tests can await window expiry deterministically + /// (instead of racing a fixed sleep against the scheduler). + private(set) var expiryTask: Task? init(duration: TimeInterval = 1) { self.duration = duration diff --git a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift index e4d025f..a596f51 100644 --- a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift +++ b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift @@ -24,11 +24,19 @@ final class CoverCardViewModel: Identifiable { var name: String { cover.name } var isAvailable: Bool { cover.isAvailable } var deviceId: String? { cover.deviceId } - var position: Int? { + var position: Int? { cover.currentPosition } + var state: CoverEntity.State { cover.state } + + /** + Pending-aware position for the slider control only. Everything that reflects + server truth (the card, subtitle, and `iconTapped`) uses `position` — the + slider needs the optimistic pending value so it reconciles on failure via + the observable cooldown expiry without ever showing unconfirmed state on the card. + */ + var sliderPosition: Int? { if cooldown.isSuppressed, let pending = pendingPosition { return pending } return cover.currentPosition } - var state: CoverEntity.State { cover.state } private var pendingPosition: Int? private let cooldown: CommitCooldown diff --git a/Hemera/Entities/Cover/UI/CoverControlPanel.swift b/Hemera/Entities/Cover/UI/CoverControlPanel.swift index 55b021b..5ceb6af 100644 --- a/Hemera/Entities/Cover/UI/CoverControlPanel.swift +++ b/Hemera/Entities/Cover/UI/CoverControlPanel.swift @@ -13,7 +13,7 @@ struct CoverControlPanel: View { } private var initialValue: Double { - guard let position = viewModel.position else { return 0 } + guard let position = viewModel.sliderPosition else { return 0 } return Double(100 - position) / 100.0 } @@ -48,7 +48,7 @@ struct CoverControlPanel: View { .onChange(of: currentMode) { _, newMode in viewModel.preferredControlMode = newMode } - .onChange(of: viewModel.position) { _, _ in + .onChange(of: viewModel.sliderPosition) { _, _ in value = initialValue } .onChange(of: viewModel.supportedControlModes) { _, newModes in diff --git a/Hemera/Entities/Light/UI/LightCardViewModel.swift b/Hemera/Entities/Light/UI/LightCardViewModel.swift index 7d9259b..1630f2e 100644 --- a/Hemera/Entities/Light/UI/LightCardViewModel.swift +++ b/Hemera/Entities/Light/UI/LightCardViewModel.swift @@ -105,6 +105,7 @@ final class LightCardViewModel: Identifiable { func setBrightness(to value: Int) { guard light.isAvailable else { return } + resetPending() pendingBrightness = value cooldown.commit() Task { @@ -114,6 +115,7 @@ final class LightCardViewModel: Identifiable { func setColorTemp(to mireds: Int) { guard light.isAvailable else { return } + resetPending() pendingColorTemp = mireds cooldown.commit() Task { @@ -123,12 +125,25 @@ final class LightCardViewModel: Identifiable { func setHSColor(hue: Double, saturation: Double) { guard light.isAvailable else { return } + resetPending() pendingHSColor = [hue, saturation] cooldown.commit() Task { await controller.setHSColor(id, hue: hue, saturation: saturation) } } + + /** + One cooldown gates all three pending values, so every commit clears the + others first — otherwise a stale pending from an earlier commit would + resurface (and mask server truth) when a different property is committed + within the shared window. + */ + private func resetPending() { + pendingBrightness = nil + pendingColorTemp = nil + pendingHSColor = nil + } } // MARK: - Factory Registration diff --git a/HemeraTests/Entities/ClimateCardViewModelTests.swift b/HemeraTests/Entities/ClimateCardViewModelTests.swift index ea591ed..446d1db 100644 --- a/HemeraTests/Entities/ClimateCardViewModelTests.swift +++ b/HemeraTests/Entities/ClimateCardViewModelTests.swift @@ -297,8 +297,8 @@ struct ClimateCardViewModelTests { // MARK: - Target Temperature Cooldown @Test - func targetTemperature_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { - let cooldown = CommitCooldown(duration: 0.1) + func targetTemperature_whileSuppressed_returnsPending_thenModelAfterExpiry() async { + let cooldown = CommitCooldown(duration: 0.05) let climate = ClimateEntity( entityId: "climate.test", name: "Test Climate", @@ -317,9 +317,10 @@ struct ClimateCardViewModelTests { /** No state_changed arrives (failed commit): after the window the value - must reconcile back to the model (server truth). + must reconcile back to the model (server truth). Await the expiry task + directly so scheduler contention can't flake the result. */ - try await Task.sleep(for: .milliseconds(250)) + await cooldown.expiryTask?.value #expect(vm.targetTemperature == 20) } diff --git a/HemeraTests/Entities/CommitCooldownTests.swift b/HemeraTests/Entities/CommitCooldownTests.swift index e5bbe84..9627d04 100644 --- a/HemeraTests/Entities/CommitCooldownTests.swift +++ b/HemeraTests/Entities/CommitCooldownTests.swift @@ -23,35 +23,36 @@ struct CommitCooldownTests { } @Test - func commit_expiresAfterDuration() async throws { - let cooldown = CommitCooldown(duration: 0.1) + func commit_expiresAfterDuration() async { + let cooldown = CommitCooldown(duration: 0.05) cooldown.commit() #expect(cooldown.isSuppressed == true) - try await Task.sleep(for: .milliseconds(250)) + // Await the expiry task itself rather than a fixed sleep, so scheduler + // contention can't flake the result. + await cooldown.expiryTask?.value #expect(cooldown.isSuppressed == false) } // MARK: - Debounce @Test - func commit_recommit_measuresWindowFromLastCommit() async throws { - let cooldown = CommitCooldown(duration: 0.2) + func commit_recommit_cancelsPreviousExpiryAndRearmsWindow() async { + let cooldown = CommitCooldown(duration: 0.05) cooldown.commit() + let firstExpiry = cooldown.expiryTask /** - Re-commit before the first window expires; the window must now be - measured from this second commit, not the first. + Re-commit before the first window expires (no suspension has occurred, + so the first timer cannot have fired yet): the prior timer is cancelled + and a fresh one armed, so the window is measured from the last commit. */ - try await Task.sleep(for: .milliseconds(120)) cooldown.commit() - - // Past the original 200ms window but within the extended one. - try await Task.sleep(for: .milliseconds(120)) + #expect(firstExpiry?.isCancelled == true) #expect(cooldown.isSuppressed == true) - // Past the extended window. - try await Task.sleep(for: .milliseconds(250)) + // The re-armed window still expires. + await cooldown.expiryTask?.value #expect(cooldown.isSuppressed == false) } } diff --git a/HemeraTests/Entities/CoverCardViewModelTests.swift b/HemeraTests/Entities/CoverCardViewModelTests.swift index 71fee8d..b3d96b5 100644 --- a/HemeraTests/Entities/CoverCardViewModelTests.swift +++ b/HemeraTests/Entities/CoverCardViewModelTests.swift @@ -224,8 +224,8 @@ struct CoverCardViewModelTests { // MARK: - Position Cooldown @Test - func position_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { - let cooldown = CommitCooldown(duration: 0.1) + func sliderPosition_whileSuppressed_returnsPending_thenModelAfterExpiry() async { + let cooldown = CommitCooldown(duration: 0.05) let cover = CoverEntity( entityId: "cover.test", name: "Test Cover", @@ -235,17 +235,36 @@ struct CoverCardViewModelTests { ) let vm = CoverCardViewModel(cover: cover, controller: SpyCoverControlling(), cooldown: cooldown) - #expect(vm.position == 30) + #expect(vm.sliderPosition == 30) // Commit a new position; the model is unchanged (server has not confirmed). vm.setPosition(to: 80) - #expect(vm.position == 80) + #expect(vm.sliderPosition == 80) /** No state_changed arrives (failed commit): after the window the slider - value must reconcile back to the model (server truth). + value must reconcile back to the model (server truth). Await the expiry + task directly so scheduler contention can't flake the result. */ - try await Task.sleep(for: .milliseconds(250)) + await cooldown.expiryTask?.value + #expect(vm.sliderPosition == 30) + } + + @Test + func position_duringCooldown_staysServerTruth() { + let cooldown = CommitCooldown(duration: 1) + let cover = CoverEntity( + entityId: "cover.test", + name: "Test Cover", + state: .open, + currentPosition: 30, + supportedFeaturesRaw: CoverEntity.Features.setPosition.rawValue + ) + let vm = CoverCardViewModel(cover: cover, controller: SpyCoverControlling(), cooldown: cooldown) + + // The card/subtitle/iconTapped surface must never show unconfirmed state: + // position stays the model value even while a commit is pending. + vm.setPosition(to: 80) #expect(vm.position == 30) } diff --git a/HemeraTests/Entities/LightCardViewModelTests.swift b/HemeraTests/Entities/LightCardViewModelTests.swift index a6607da..4f43cea 100644 --- a/HemeraTests/Entities/LightCardViewModelTests.swift +++ b/HemeraTests/Entities/LightCardViewModelTests.swift @@ -46,8 +46,8 @@ struct LightCardViewModelTests { // MARK: - Brightness Cooldown @Test - func brightness_whileSuppressed_returnsPending_thenModelAfterExpiry() async throws { - let cooldown = CommitCooldown(duration: 0.1) + func brightness_whileSuppressed_returnsPending_thenModelAfterExpiry() async { + let cooldown = CommitCooldown(duration: 0.05) let light = LightEntity(entityId: "light.test", name: "Test", state: .on, brightness: 100) let vm = LightCardViewModel(light: light, controller: StubLightControlling(), cooldown: cooldown) @@ -59,12 +59,31 @@ struct LightCardViewModelTests { /** No state_changed arrives (failed commit): after the window the slider - value must reconcile back to the model (server truth). + value must reconcile back to the model (server truth). Await the expiry + task directly so scheduler contention can't flake the result. */ - try await Task.sleep(for: .milliseconds(250)) + await cooldown.expiryTask?.value #expect(vm.brightness == 100) } + @Test + func brightness_afterCommittingAnotherProperty_doesNotResurfaceStalePending() { + // Long window so the cooldown stays suppressed for the whole test. + let cooldown = CommitCooldown(duration: 1) + let light = LightEntity(entityId: "light.test", name: "Test", state: .on, brightness: 100) + let vm = LightCardViewModel(light: light, controller: StubLightControlling(), cooldown: cooldown) + + // Brightness commit that the server never confirms (model stays 100). + vm.setBrightness(to: 200) + #expect(vm.brightness == 200) + + // Committing a different property re-arms the shared cooldown; the stale + // brightness pending must not resurface — brightness reflects the model. + vm.setColorTemp(to: 250) + #expect(vm.brightness == 100) + #expect(vm.colorTemp == 250) + } + // MARK: - Helpers private func makeViewModel(state: LightEntity.State) -> LightCardViewModel {