Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Hemera/Entities/Climate/UI/ClimateCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,19 +214,33 @@ 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) }
}

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) }
Expand Down
34 changes: 24 additions & 10 deletions Hemera/Entities/CommitCooldown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

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
}
}
}
19 changes: 18 additions & 1 deletion Hemera/Entities/Cover/UI/CoverCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions Hemera/Entities/Cover/UI/CoverControlPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions Hemera/Entities/Light/UI/LightCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ final class LightCardViewModel: Identifiable {

func setBrightness(to value: Int) {
guard light.isAvailable else { return }
resetPending()
pendingBrightness = value
cooldown.commit()
Task {
Expand All @@ -114,6 +115,7 @@ final class LightCardViewModel: Identifiable {

func setColorTemp(to mireds: Int) {
guard light.isAvailable else { return }
resetPending()
pendingColorTemp = mireds
cooldown.commit()
Task {
Expand All @@ -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
Expand Down
16 changes: 4 additions & 12 deletions Hemera/Entities/Switch/UI/SwitchControlPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
30 changes: 30 additions & 0 deletions HemeraTests/Entities/ClimateCardViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions HemeraTests/Entities/CommitCooldownTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
47 changes: 47 additions & 0 deletions HemeraTests/Entities/CoverCardViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading