diff --git a/Hemera/Entities/Automation/UI/AutomationCardViewModel.swift b/Hemera/Entities/Automation/UI/AutomationCardViewModel.swift index 667a4c5..608f41f 100644 --- a/Hemera/Entities/Automation/UI/AutomationCardViewModel.swift +++ b/Hemera/Entities/Automation/UI/AutomationCardViewModel.swift @@ -57,6 +57,7 @@ final class AutomationCardViewModel: Identifiable { extension AutomationCardViewModel { static func registration(controller: AutomationControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: AutomationEntity.domain, makeViewModelsForArea: { area in area.automations.sorted(by: { $0.entityId < $1.entityId }).map { AutomationCardViewModel(automation: $0, controller: controller) @@ -65,6 +66,9 @@ extension AutomationCardViewModel { makeViewModelForEntityId: { entityId, context in guard let automation = AutomationEntity.fetch(byId: entityId, in: context) else { return nil } return AutomationCardViewModel(automation: automation, controller: controller) + }, + entityExists: { entityId, context in + AutomationEntity.fetch(byId: entityId, in: context) != nil } ) } @@ -77,6 +81,8 @@ extension AutomationCardViewModel: EntityCardViewModel { AnyView(AutomationCard(viewModel: self)) } + var hasOverlay: Bool { true } + func makeOverlayView(isPresented: Binding) -> AnyView? { AnyView(AutomationControlPanel(viewModel: self, isPresented: isPresented)) } diff --git a/Hemera/Entities/BinarySensor/UI/BinarySensorCardViewModel.swift b/Hemera/Entities/BinarySensor/UI/BinarySensorCardViewModel.swift index 245b19f..d73c61d 100644 --- a/Hemera/Entities/BinarySensor/UI/BinarySensorCardViewModel.swift +++ b/Hemera/Entities/BinarySensor/UI/BinarySensorCardViewModel.swift @@ -62,6 +62,7 @@ final class BinarySensorCardViewModel: Identifiable { extension BinarySensorCardViewModel { static func registration() -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: BinarySensorEntity.domain, makeViewModelsForArea: { area in area.binarySensors.sorted(by: { $0.entityId < $1.entityId }).map { BinarySensorCardViewModel(binarySensor: $0) @@ -70,6 +71,9 @@ extension BinarySensorCardViewModel { makeViewModelForEntityId: { entityId, context in guard let binarySensor = BinarySensorEntity.fetch(byId: entityId, in: context) else { return nil } return BinarySensorCardViewModel(binarySensor: binarySensor) + }, + entityExists: { entityId, context in + BinarySensorEntity.fetch(byId: entityId, in: context) != nil } ) } diff --git a/Hemera/Entities/Button/UI/ButtonCardViewModel.swift b/Hemera/Entities/Button/UI/ButtonCardViewModel.swift index fedbc87..804da6e 100644 --- a/Hemera/Entities/Button/UI/ButtonCardViewModel.swift +++ b/Hemera/Entities/Button/UI/ButtonCardViewModel.swift @@ -67,6 +67,7 @@ final class ButtonCardViewModel: Identifiable { extension ButtonCardViewModel { static func registration(controller: ButtonControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: ButtonEntity.domain, makeViewModelsForArea: { area in area.buttons.filter { $0.deviceClass.isUserActionable }.sorted(by: { $0.entityId < $1.entityId }).map { ButtonCardViewModel(button: $0, controller: controller) @@ -76,6 +77,9 @@ extension ButtonCardViewModel { guard let button = ButtonEntity.fetch(byId: entityId, in: context), button.deviceClass.isUserActionable else { return nil } return ButtonCardViewModel(button: button, controller: controller) + }, + entityExists: { entityId, context in + ButtonEntity.fetch(byId: entityId, in: context) != nil } ) } diff --git a/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift b/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift index 1add9b7..58eac75 100644 --- a/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift +++ b/Hemera/Entities/Climate/UI/ClimateCardViewModel.swift @@ -361,6 +361,7 @@ extension ClimateCardViewModel { extension ClimateCardViewModel { static func registration(controller: ClimateControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: ClimateEntity.domain, makeViewModelsForArea: { area in area.climates.sorted(by: { $0.entityId < $1.entityId }).map { ClimateCardViewModel(climate: $0, controller: controller) @@ -369,6 +370,9 @@ extension ClimateCardViewModel { makeViewModelForEntityId: { entityId, context in guard let climate = ClimateEntity.fetch(byId: entityId, in: context) else { return nil } return ClimateCardViewModel(climate: climate, controller: controller) + }, + entityExists: { entityId, context in + ClimateEntity.fetch(byId: entityId, in: context) != nil } ) } @@ -381,6 +385,8 @@ extension ClimateCardViewModel: EntityCardViewModel { AnyView(ClimateCard(viewModel: self)) } + var hasOverlay: Bool { true } + func makeOverlayView(isPresented: Binding) -> AnyView? { AnyView(ClimateControlPanel(viewModel: self, isPresented: isPresented)) } diff --git a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift index 2faa555..c0acf97 100644 --- a/Hemera/Entities/Cover/UI/CoverCardViewModel.swift +++ b/Hemera/Entities/Cover/UI/CoverCardViewModel.swift @@ -244,6 +244,7 @@ private extension CoverCardViewModel { extension CoverCardViewModel { static func registration(controller: CoverControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: CoverEntity.domain, makeViewModelsForArea: { area in area.covers.sorted(by: { $0.entityId < $1.entityId }).map { CoverCardViewModel(cover: $0, controller: controller) @@ -252,6 +253,9 @@ extension CoverCardViewModel { makeViewModelForEntityId: { entityId, context in guard let cover = CoverEntity.fetch(byId: entityId, in: context) else { return nil } return CoverCardViewModel(cover: cover, controller: controller) + }, + entityExists: { entityId, context in + CoverEntity.fetch(byId: entityId, in: context) != nil } ) } @@ -264,6 +268,8 @@ extension CoverCardViewModel: EntityCardViewModel { AnyView(CoverCard(viewModel: self)) } + var hasOverlay: Bool { true } + func makeOverlayView(isPresented: Binding) -> AnyView? { AnyView(CoverControlPanel(viewModel: self, isPresented: isPresented)) } diff --git a/Hemera/Entities/EntityCardViewModel.swift b/Hemera/Entities/EntityCardViewModel.swift index b3c6929..9926c3f 100644 --- a/Hemera/Entities/EntityCardViewModel.swift +++ b/Hemera/Entities/EntityCardViewModel.swift @@ -19,6 +19,12 @@ protocol EntityCardViewModel: AnyObject, Observable, Identifiable where ID == St @ViewBuilder func makeCardView() -> AnyView + /** + Whether this entity presents a detail overlay when its card body is tapped. + Must stay in sync with `makeOverlayView` — `true` exactly when it returns non-nil. + */ + var hasOverlay: Bool { get } + /// Creates the overlay view for this entity, if any. /// Return `nil` for entities that have no detail overlay (e.g. scenes). func makeOverlayView(isPresented: Binding) -> AnyView? @@ -29,6 +35,7 @@ protocol EntityCardViewModel: AnyObject, Observable, Identifiable where ID == St } extension EntityCardViewModel { + var hasOverlay: Bool { false } func makeOverlayView(isPresented: Binding) -> AnyView? { nil } func performPrimaryAction() { } } diff --git a/Hemera/Entities/Light/UI/LightCard.swift b/Hemera/Entities/Light/UI/LightCard.swift index f270d73..8bcb55e 100644 --- a/Hemera/Entities/Light/UI/LightCard.swift +++ b/Hemera/Entities/Light/UI/LightCard.swift @@ -22,7 +22,7 @@ struct LightCard: View { subtitle: viewModel.isOn ? Localization.on : Localization.off, accessibilityIdentifier: viewModel.id) } backgroundOverlay: { - if isMediumTile { + if isMediumTile && viewModel.isDimmable { CardFillOverlay(fraction: fillFraction, fillColor: viewModel.tintColor, anchor: .bottom) } } diff --git a/Hemera/Entities/Light/UI/LightCardViewModel.swift b/Hemera/Entities/Light/UI/LightCardViewModel.swift index 1630f2e..f6f4b73 100644 --- a/Hemera/Entities/Light/UI/LightCardViewModel.swift +++ b/Hemera/Entities/Light/UI/LightCardViewModel.swift @@ -51,6 +51,8 @@ final class LightCardViewModel: Identifiable { var supportedModes: [LightControlMode] { guard let modes = light.supportedColorModes else { return [.brightness] } + // A light whose only color mode is "onoff" has no brightness/color controls. + if Set(modes) == ["onoff"] { return [] } var result: [LightControlMode] = [.brightness] if modes.contains("color_temp") { if light.minMireds != nil && light.maxMireds != nil { @@ -66,6 +68,12 @@ final class LightCardViewModel: Identifiable { return result } + /** + Whether the light exposes a brightness control (i.e. is dimmable). + `false` for on/off-only lights, which drive neither the fill nor the overlay. + */ + var isDimmable: Bool { supportedModes.contains(.brightness) } + nonisolated let id: String var name: String { light.name } var isOn: Bool { light.isOn } @@ -151,6 +159,7 @@ final class LightCardViewModel: Identifiable { extension LightCardViewModel { static func registration(controller: LightControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: LightEntity.domain, makeViewModelsForArea: { area in area.lights.sorted(by: { $0.entityId < $1.entityId }).map { LightCardViewModel(light: $0, controller: controller) @@ -159,6 +168,9 @@ extension LightCardViewModel { makeViewModelForEntityId: { entityId, context in guard let light = LightEntity.fetch(byId: entityId, in: context) else { return nil } return LightCardViewModel(light: light, controller: controller) + }, + entityExists: { entityId, context in + LightEntity.fetch(byId: entityId, in: context) != nil } ) } @@ -171,7 +183,14 @@ extension LightCardViewModel: EntityCardViewModel { AnyView(LightCard(viewModel: self)) } + /** + On/off-only lights have no controllable modes, so they present no overlay — + the card icon toggle is their only control. Keep in sync with `makeOverlayView`. + */ + var hasOverlay: Bool { !supportedModes.isEmpty } + func makeOverlayView(isPresented: Binding) -> AnyView? { - AnyView(LightControlPanel(viewModel: self, isPresented: isPresented)) + guard hasOverlay else { return nil } + return AnyView(LightControlPanel(viewModel: self, isPresented: isPresented)) } } diff --git a/Hemera/Entities/Scene/UI/SceneCardViewModel.swift b/Hemera/Entities/Scene/UI/SceneCardViewModel.swift index 512fe74..3a9f799 100644 --- a/Hemera/Entities/Scene/UI/SceneCardViewModel.swift +++ b/Hemera/Entities/Scene/UI/SceneCardViewModel.swift @@ -41,6 +41,7 @@ final class SceneCardViewModel: Identifiable { extension SceneCardViewModel { static func registration(controller: SceneControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: SceneEntity.domain, makeViewModelsForArea: { area in area.scenes.sorted(by: { $0.entityId < $1.entityId }).map { SceneCardViewModel(scene: $0, controller: controller) @@ -49,6 +50,9 @@ extension SceneCardViewModel { makeViewModelForEntityId: { entityId, context in guard let scene = SceneEntity.fetch(byId: entityId, in: context) else { return nil } return SceneCardViewModel(scene: scene, controller: controller) + }, + entityExists: { entityId, context in + SceneEntity.fetch(byId: entityId, in: context) != nil } ) } diff --git a/Hemera/Entities/Switch/UI/SwitchCardViewModel.swift b/Hemera/Entities/Switch/UI/SwitchCardViewModel.swift index 1274374..9b05af1 100644 --- a/Hemera/Entities/Switch/UI/SwitchCardViewModel.swift +++ b/Hemera/Entities/Switch/UI/SwitchCardViewModel.swift @@ -60,6 +60,7 @@ final class SwitchCardViewModel: Identifiable { extension SwitchCardViewModel { static func registration(controller: SwitchControlling) -> ViewModelFactory.Registration { ViewModelFactory.Registration( + domain: SwitchEntity.domain, makeViewModelsForArea: { area in area.switches.sorted(by: { $0.entityId < $1.entityId }).map { SwitchCardViewModel(switchEntity: $0, controller: controller) @@ -68,6 +69,9 @@ extension SwitchCardViewModel { makeViewModelForEntityId: { entityId, context in guard let switchEntity = SwitchEntity.fetch(byId: entityId, in: context) else { return nil } return SwitchCardViewModel(switchEntity: switchEntity, controller: controller) + }, + entityExists: { entityId, context in + SwitchEntity.fetch(byId: entityId, in: context) != nil } ) } @@ -80,6 +84,8 @@ extension SwitchCardViewModel: EntityCardViewModel { AnyView(SwitchCard(viewModel: self)) } + var hasOverlay: Bool { true } + func makeOverlayView(isPresented: Binding) -> AnyView? { AnyView(SwitchControlPanel(viewModel: self, isPresented: isPresented)) } diff --git a/Hemera/Entities/ViewModelFactory.swift b/Hemera/Entities/ViewModelFactory.swift index 0f278f3..e621921 100644 --- a/Hemera/Entities/ViewModelFactory.swift +++ b/Hemera/Entities/ViewModelFactory.swift @@ -16,13 +16,19 @@ final class ViewModelFactory { /// A pair of closures that create ViewModels for a single entity domain. struct Registration { + /// The Home Assistant domain string this registration owns (e.g. `"light"`). + let domain: String /// Creates all ViewModels for entities of this domain within an area. let makeViewModelsForArea: @MainActor (AreaEntity) -> [any EntityCardViewModel] /// Creates a ViewModel for a single entity by ID, if it exists in storage. let makeViewModelForEntityId: @MainActor (String, ModelContext) -> (any EntityCardViewModel)? + /// Whether an entity of this domain with the given ID still exists in storage. + /// A cheap existence probe that does not allocate a ViewModel. + let entityExists: @MainActor (String, ModelContext) -> Bool } private var registrations: [Registration] = [] + private var registrationsByDomain: [String: Registration] = [:] private var cache: [String: any EntityCardViewModel] = [:] private let context: ModelContext /// Retains the container so its entities stay valid as long as this factory (and any VMs it created) are alive. @@ -38,6 +44,7 @@ final class ViewModelFactory { /// Registers a domain's ViewModel factory. func register(_ registration: Registration) { registrations.append(registration) + registrationsByDomain[registration.domain] = registration } /// Registers all built-in entity domain factories. @@ -78,7 +85,16 @@ final class ViewModelFactory { /// Returns the cached VM for the entity, or creates one by looking it up in storage. func makeViewModel(forEntityId entityId: String) -> (any EntityCardViewModel)? { - if let cached = cache[entityId] { return cached } + if let cached = cache[entityId] { + /** + Re-confirm the backing @Model still exists before returning the cached + VM; if the entity was deleted mid-session, drop the stale VM so callers + never touch an invalidated @Model. The cached instance is still returned + for live entities, preserving ephemeral interaction state. + */ + if entityExists(entityId) { return cached } + cache[entityId] = nil + } for registration in registrations { if let vm = registration.makeViewModelForEntityId(entityId, context) { cache[entityId] = vm @@ -88,6 +104,15 @@ final class ViewModelFactory { return nil } + /// Whether the entity backing a cached VM still exists in storage. Targets the + /// single registration owning the id's domain (HA ids are `"."`) + /// so the probe is one cheap fetch, not a fan-out across every domain. + private func entityExists(_ entityId: String) -> Bool { + let domain = String(entityId.prefix { $0 != "." }) + guard let registration = registrationsByDomain[domain] else { return false } + return registration.entityExists(entityId, context) + } + /// If a VM for this entityId is already cached, return the cached instance; /// otherwise adopt the freshly-built one. private func cachedOrAdopt(_ fresh: any EntityCardViewModel) -> any EntityCardViewModel { @@ -103,7 +128,7 @@ final class ViewModelFactory { /// is unavailable or its primary action was invoked in place. func handleCardTap(entityId: String) -> (any EntityCardViewModel)? { guard let vm = makeViewModel(forEntityId: entityId), vm.isAvailable else { return nil } - if vm.makeOverlayView(isPresented: .constant(true)) != nil { + if vm.hasOverlay { return vm } vm.performPrimaryAction() diff --git a/HemeraTests/Entities/AutomationCardViewModelTests.swift b/HemeraTests/Entities/AutomationCardViewModelTests.swift index 1dc01b8..ded080e 100644 --- a/HemeraTests/Entities/AutomationCardViewModelTests.swift +++ b/HemeraTests/Entities/AutomationCardViewModelTests.swift @@ -53,6 +53,15 @@ struct AutomationCardViewModelTests { #expect(vm.iconName == "gearshape.2.fill") } + // MARK: - Has Overlay + + @Test + func hasOverlay_isTrue() { + let vm = makeViewModel(state: .on) + #expect(vm.hasOverlay) + #expect(vm.makeOverlayView(isPresented: .constant(true)) != nil) + } + // MARK: - Helpers private func makeViewModel(state: AutomationEntity.State) -> AutomationCardViewModel { diff --git a/HemeraTests/Entities/LightCardViewModelTests.swift b/HemeraTests/Entities/LightCardViewModelTests.swift index 4f43cea..c6e9dc1 100644 --- a/HemeraTests/Entities/LightCardViewModelTests.swift +++ b/HemeraTests/Entities/LightCardViewModelTests.swift @@ -84,12 +84,88 @@ struct LightCardViewModelTests { #expect(vm.colorTemp == 250) } + // MARK: - Supported Modes + + @Test + func supportedModes_onoffOnly_isEmpty() { + let vm = makeViewModel(supportedColorModes: ["onoff"]) + #expect(vm.supportedModes.isEmpty) + } + + @Test + func supportedModes_brightnessOnly_containsBrightness() { + let vm = makeViewModel(supportedColorModes: ["brightness"]) + #expect(vm.supportedModes == [.brightness]) + } + + @Test + func supportedModes_absent_defaultsToBrightness() { + let vm = makeViewModel(supportedColorModes: nil) + #expect(vm.supportedModes == [.brightness]) + } + + @Test + func supportedModes_colorTempAndHue_includesAllControls() { + let vm = makeViewModel( + supportedColorModes: ["color_temp", "hs"], + minMireds: 153, + maxMireds: 500 + ) + #expect(vm.supportedModes == [.brightness, .colorTemp, .hue]) + } + + // MARK: - Is Dimmable + + @Test + func isDimmable_onoffOnly_isFalse() { + let vm = makeViewModel(supportedColorModes: ["onoff"]) + #expect(vm.isDimmable == false) + } + + @Test + func isDimmable_brightness_isTrue() { + let vm = makeViewModel(supportedColorModes: ["brightness"]) + #expect(vm.isDimmable) + } + + // MARK: - Has Overlay + + @Test + func hasOverlay_onoffOnly_isFalse() { + let vm = makeViewModel(supportedColorModes: ["onoff"]) + #expect(vm.hasOverlay == false) + #expect(vm.makeOverlayView(isPresented: .constant(true)) == nil) + } + + @Test + func hasOverlay_dimmable_isTrue() { + let vm = makeViewModel(supportedColorModes: ["brightness"]) + #expect(vm.hasOverlay) + #expect(vm.makeOverlayView(isPresented: .constant(true)) != nil) + } + // MARK: - Helpers private func makeViewModel(state: LightEntity.State) -> LightCardViewModel { let light = LightEntity(entityId: "light.test", name: "Test", state: state) return LightCardViewModel(light: light, controller: StubLightControlling()) } + + private func makeViewModel( + supportedColorModes: [String]?, + minMireds: Int? = nil, + maxMireds: Int? = nil + ) -> LightCardViewModel { + let light = LightEntity( + entityId: "light.test", + name: "Test", + state: .on, + minMireds: minMireds, + maxMireds: maxMireds, + supportedColorModes: supportedColorModes + ) + return LightCardViewModel(light: light, controller: StubLightControlling()) + } } @MainActor diff --git a/HemeraTests/Entities/SceneCardViewModelTests.swift b/HemeraTests/Entities/SceneCardViewModelTests.swift index e6c270b..9108d0c 100644 --- a/HemeraTests/Entities/SceneCardViewModelTests.swift +++ b/HemeraTests/Entities/SceneCardViewModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Testing @testable import Hemera @@ -39,6 +40,15 @@ struct SceneCardViewModelTests { #expect(spy.activatedIds == ["scene.test"]) } + // MARK: - Has Overlay + + @Test + func hasOverlay_isFalse() { + let vm = makeViewModel() + #expect(vm.hasOverlay == false) + #expect(vm.makeOverlayView(isPresented: .constant(true)) == nil) + } + // MARK: - Helpers private func makeViewModel(icon: String? = nil, controller: SceneControlling? = nil) -> SceneCardViewModel { diff --git a/HemeraTests/Entities/SwitchCardViewModelTests.swift b/HemeraTests/Entities/SwitchCardViewModelTests.swift index 02c6a3e..38cba32 100644 --- a/HemeraTests/Entities/SwitchCardViewModelTests.swift +++ b/HemeraTests/Entities/SwitchCardViewModelTests.swift @@ -133,6 +133,15 @@ struct SwitchCardViewModelTests { #expect(vm.deviceClass == .switch) } + // MARK: - Has Overlay + + @Test + func hasOverlay_isTrue() { + let vm = makeViewModel(deviceClass: .outlet, state: .on) + #expect(vm.hasOverlay) + #expect(vm.makeOverlayView(isPresented: .constant(true)) != nil) + } + // MARK: - Helpers private func makeViewModel( diff --git a/HemeraTests/Entities/ViewModelFactoryTests.swift b/HemeraTests/Entities/ViewModelFactoryTests.swift new file mode 100644 index 0000000..cb420cc --- /dev/null +++ b/HemeraTests/Entities/ViewModelFactoryTests.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData +import Testing +@testable import Hemera + +@MainActor +struct ViewModelFactoryTests { + + // MARK: - Cache Eviction + + @Test + func makeViewModel_afterBackingModelDeleted_returnsNil() throws { + let (factory, context) = makeFactory() + let light = LightEntity(entityId: "light.test", name: "Test", state: .on) + context.insert(light) + try context.save() + + // Cache a VM for the entity. + let cached = factory.makeViewModel(forEntityId: "light.test") + #expect(cached != nil) + + // Delete the backing model mid-session, then look up again. + context.delete(light) + try context.save() + + #expect(factory.makeViewModel(forEntityId: "light.test") == nil) + } + + @Test + func makeViewModel_forLiveEntity_returnsSameCachedInstance() throws { + let (factory, context) = makeFactory() + let light = LightEntity(entityId: "light.test", name: "Test", state: .on) + context.insert(light) + try context.save() + + let first = factory.makeViewModel(forEntityId: "light.test") + let second = factory.makeViewModel(forEntityId: "light.test") + + #expect(first != nil) + #expect((first as AnyObject) === (second as AnyObject)) + } + + @Test + func makeViewModel_cachedLookup_probesOnlyTheOwningDomain() throws { + let (factory, context) = makeFactory() + let light = LightEntity(entityId: "light.test", name: "Test", state: .on) + context.insert(light) + try context.save() + + // Register a second domain whose existence probe records every call. + let otherDomainProbes = CallCounter() + factory.register( + ViewModelFactory.Registration( + domain: "switch", + makeViewModelsForArea: { _ in [] }, + makeViewModelForEntityId: { _, _ in nil }, + entityExists: { _, _ in + otherDomainProbes.count += 1 + return false + } + ) + ) + + // First call caches; second call re-validates the cached VM. + _ = factory.makeViewModel(forEntityId: "light.test") + _ = factory.makeViewModel(forEntityId: "light.test") + + // The light lookup must target only the "light" registration, never fan out + // to the "switch" one. + #expect(otherDomainProbes.count == 0) + } + + // MARK: - Helpers + + private func makeFactory() -> (ViewModelFactory, ModelContext) { + let schema = Schema([LightEntity.self, AreaEntity.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: schema, configurations: config) + let context = container.mainContext + let factory = ViewModelFactory(context: context, container: container) + factory.register(LightCardViewModel.registration(controller: StubLightControlling())) + return (factory, context) + } +} + +@MainActor +private final class CallCounter { + var count = 0 +} + +@MainActor +private final class StubLightControlling: LightControlling { + func setLight(_ id: String, on: Bool) async {} + func setBrightness(_ id: String, to brightness: Int) async {} + func setColorTemp(_ id: String, to mireds: Int) async {} + func setHSColor(_ id: String, hue: Double, saturation: Double) async {} +}