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 5dc1385..0c9543a 100644 --- a/Hemera/Entities/CommitCooldown.swift +++ b/Hemera/Entities/CommitCooldown.swift @@ -21,21 +21,35 @@ 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 - init(now: @escaping @Sendable () -> Date = { Date() }) { - self.now = now - } + /// Exposed read-only so tests can await window expiry deterministically + /// (instead of racing a fixed sleep against the scheduler). + private(set) var expiryTask: Task? - 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..a596f51 100644 --- a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift +++ b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift @@ -27,6 +27,19 @@ final class CoverCardViewModel: Identifiable { 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 + } + + private var pendingPosition: Int? + private let cooldown: CommitCooldown private let controller: CoverControlling private(set) var actionTask: Task? @@ -109,7 +122,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 +134,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/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/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..446d1db 100644 --- a/HemeraTests/Entities/ClimateCardViewModelTests.swift +++ b/HemeraTests/Entities/ClimateCardViewModelTests.swift @@ -294,6 +294,36 @@ struct ClimateCardViewModelTests { #expect(vm.availableHVACModes == [.off, .heat]) } + // MARK: - Target Temperature Cooldown + + @Test + func targetTemperature_whileSuppressed_returnsPending_thenModelAfterExpiry() async { + let cooldown = CommitCooldown(duration: 0.05) + 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). Await the expiry task + directly so scheduler contention can't flake the result. + */ + await cooldown.expiryTask?.value + #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..9627d04 --- /dev/null +++ b/HemeraTests/Entities/CommitCooldownTests.swift @@ -0,0 +1,58 @@ +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 { + let cooldown = CommitCooldown(duration: 0.05) + cooldown.commit() + #expect(cooldown.isSuppressed == true) + + // 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_cancelsPreviousExpiryAndRearmsWindow() async { + let cooldown = CommitCooldown(duration: 0.05) + cooldown.commit() + let firstExpiry = cooldown.expiryTask + + /** + 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. + */ + cooldown.commit() + #expect(firstExpiry?.isCancelled == true) + #expect(cooldown.isSuppressed == true) + + // 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 9f62978..b3d96b5 100644 --- a/HemeraTests/Entities/CoverCardViewModelTests.swift +++ b/HemeraTests/Entities/CoverCardViewModelTests.swift @@ -221,6 +221,53 @@ struct CoverCardViewModelTests { #expect(vm.tintColor == .blue) } + // MARK: - Position Cooldown + + @Test + func sliderPosition_whileSuppressed_returnsPending_thenModelAfterExpiry() async { + let cooldown = CommitCooldown(duration: 0.05) + 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.sliderPosition == 30) + + // Commit a new position; the model is unchanged (server has not confirmed). + vm.setPosition(to: 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). Await the expiry + task directly so scheduler contention can't flake the result. + */ + 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) + } + // MARK: - Helpers private func makeViewModel( diff --git a/HemeraTests/Entities/LightCardViewModelTests.swift b/HemeraTests/Entities/LightCardViewModelTests.swift index 63b052b..4f43cea 100644 --- a/HemeraTests/Entities/LightCardViewModelTests.swift +++ b/HemeraTests/Entities/LightCardViewModelTests.swift @@ -43,6 +43,47 @@ struct LightCardViewModelTests { #expect(vm.iconName == "lightbulb.fill") } + // MARK: - Brightness Cooldown + + @Test + 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) + + #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). Await the expiry + task directly so scheduler contention can't flake the result. + */ + 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 {