diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml index 24ed2aee086..6f46a3d0367 100755 --- a/app/src/main/res/xml/changelog_master.xml +++ b/app/src/main/res/xml/changelog_master.xml @@ -5,6 +5,7 @@ Bug fixes and dependency updates + Shortcut tiles now show state-aware icons (e.g., locked vs unlocked padlock) Bug fixes and dependency updates diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt index 5153d0017a1..b5218909abf 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt @@ -35,20 +35,31 @@ import com.mikepenz.iconics.utils.sizeDp import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.getIcon import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.data.SimplifiedEntity import io.homeassistant.companion.android.util.getIcon import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlin.math.min import kotlin.math.roundToInt +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber // Dimensions (dp) private const val CIRCLE_SIZE = 56f @@ -58,6 +69,9 @@ private const val SPACING = 8f private const val TEXT_SIZE = 8f private const val TEXT_PADDING = 2f +// Bounded so a cold-cache fetch cannot exceed the Wear tile render timeout (~3s). +private const val CACHE_WARM_TIMEOUT_MS = 2000L + @AndroidEntryPoint class ShortcutsTile : TileService() { private val serviceJob = Job() @@ -70,11 +84,11 @@ class ShortcutsTile : TileService() { lateinit var wearPrefsRepository: WearPrefsRepository override fun onTileRequest(requestParams: TileRequest): ListenableFuture = serviceScope.future { - val state = requestParams.currentState - if (state.lastClickableId.isNotEmpty()) { + val clicked = requestParams.currentState.lastClickableId + if (clicked.isNotEmpty()) { Intent().also { intent -> intent.action = "io.homeassistant.companion.android.TILE_ACTION" - intent.putExtra("entity_id", state.lastClickableId) + intent.putExtra("entity_id", clicked) intent.setPackage(packageName) sendBroadcast(intent) } @@ -82,12 +96,35 @@ class ShortcutsTile : TileService() { val tileId = requestParams.tileId val entities = getEntities(tileId) + val entityIds = entities.map { it.entityId } + val isRegistered = serverManager.isRegistered() + + if (isRegistered) { + val missing = entityIds.filterNot { entityStates.containsKey(it) } + if (missing.isNotEmpty()) { + warmEntityCache(missing) + } + } + + // Optimistically flip the cached state so the render reflects the tap before the + // WebSocket subscription confirms. Required because `requestUpdate()` does not + // reliably trigger the Wear tile framework to re-render. + if (clicked.isNotEmpty()) { + entityStates.computeIfPresent(clicked) { _, current -> applyOptimisticClick(current) } + } + + // Freeze state at the moment the layout is built; the matching resource bundle must + // use the same snapshot so every resource ID the layout references is produced. + val snapshot = TileSnapshot.from(entityStates, entityIds) + val resourcesVersion = "$TAG$tileId.${System.currentTimeMillis()}" + snapshotStash.put(resourcesVersion, snapshot) Tile.Builder() - .setResourcesVersion(entities.toString()) + .setResourcesVersion(resourcesVersion) .setTileTimeline( - if (serverManager.isRegistered()) { - timeline(tileId) + if (isRegistered) { + val showLabels = wearPrefsRepository.getShowShortcutText() + Timeline.fromLayoutElement(layout(entities, showLabels, snapshot)) } else { loggedOutTimeline( this@ShortcutsTile, @@ -106,63 +143,50 @@ class ShortcutsTile : TileService() { val density = requestParams.deviceConfiguration.screenDensity val iconSizePx = (iconSize * density).roundToInt() val entities = getEntities(requestParams.tileId) + // Reuse the snapshot frozen in onTileRequest so the resource IDs here match what + // the layout referenced. Falls back to current cache if the stash has been cleared. + val snapshot = snapshotStash.get(requestParams.version) + ?: TileSnapshot.from(entityStates, entities.map { it.entityId }) Resources.Builder() - .setVersion(entities.toString()) + .setVersion(requestParams.version) .apply { - entities.map { entity -> - // Find icon and create Bitmap - val iconIIcon = getIcon( - entity.icon, - entity.domain, - this@ShortcutsTile, + entities.forEach { entity -> + val cachedEntity = snapshot.entityOf(entity.entityId) + val iconIIcon = if (cachedEntity != null) { + cachedEntity.getIcon(this@ShortcutsTile) + } else { + getIcon(entity.icon, entity.domain, this@ShortcutsTile) + } + addIdToImageMapping( + entity.resourceIdIn(snapshot), + buildIconResource(iconIIcon, iconSize, iconSizePx), ) - val iconBitmap = IconicsDrawable(this@ShortcutsTile, iconIIcon).apply { - colorInt = Color.WHITE - sizeDp = iconSize.roundToInt() - backgroundColor = IconicsColor.colorRes(R.color.colorOverlay) - }.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565) - - // Make array of bitmap - val bitmapData = ByteBuffer.allocate(iconBitmap.byteCount).apply { - iconBitmap.copyPixelsToBuffer(this) - }.array() - - // link the entity id to the bitmap data array - entity.entityId to ResourceBuilders.ImageResource.Builder() - .setInlineResource( - ResourceBuilders.InlineImageResource.Builder() - .setData(bitmapData) - .setWidthPx(iconSizePx) - .setHeightPx(iconSizePx) - .setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565) - .build(), - ) - .build() - }.forEach { (id, imageResource) -> - addIdToImageMapping(id, imageResource) } } .build() } + override fun onTileEnterEvent(requestParams: EventBuilders.TileEnterEvent) { + serviceScope.launch { + val entities = getEntities(requestParams.tileId) + // onTileRequest warms the cache on cold start; enter is only for subscription + // lifecycle. Always restart so the subscription reflects the current entity list. + stopEntitySubscription() + startEntitySubscription( + serverManager = serverManager, + entityIds = entities.map { it.entityId }, + context = this@ShortcutsTile, + ) + } + } + + override fun onTileLeaveEvent(requestParams: EventBuilders.TileLeaveEvent) { + stopEntitySubscription() + } + override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent): Unit = runBlocking { withContext(Dispatchers.IO) { - /** - * When the app is updated from an older version (which only supported a single Shortcut Tile), - * and the user is adding a new Shortcuts Tile, we can't tell for sure if it's the 1st or 2nd Tile. - * Even though we may have the shortcut list stored in the prefs, it doesn't guarantee that - * the tile was actually added to the Tiles carousel. - * The [WearPrefsRepositoryImpl::getTileShortcutsAndSaveTileId] method will handle both of the following cases: - * 1. There was no Tile added, but there were shortcuts stored in the prefs. - * In this case, the stored shortcuts will be associated to the new tileId. - * 2. There was a single Tile added, and there were shortcuts stored in the prefs. - * If there was a Tile update since updating the app, the tileId will be already - * associated to the shortcuts, because it also calls [getTileShortcutsAndSaveTileId]. - * If there was no Tile update yet, the new Tile will "steal" the shortcuts from the existing Tile, - * and the old Tile will behave as it is the new Tile. This is needed because - * we don't know if it's the 1st or 2nd Tile. - */ wearPrefsRepository.getTileShortcutsAndSaveTileId(requestParams.tileId) } } @@ -171,11 +195,13 @@ class ShortcutsTile : TileService() { withContext(Dispatchers.IO) { wearPrefsRepository.removeTileShortcuts(requestParams.tileId) } + stopEntitySubscription() + entityStates.clear() + snapshotStash.clear() } override fun onDestroy() { super.onDestroy() - // Cleans up the coroutine serviceJob.cancel() } @@ -183,110 +209,201 @@ class ShortcutsTile : TileService() { return wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId).map { SimplifiedEntity(it) } } - private suspend fun timeline(tileId: Int): Timeline { - val entities = getEntities(tileId) - val showLabels = wearPrefsRepository.getShowShortcutText() - - return Timeline.fromLayoutElement(layout(entities, showLabels)) + /** + * Fetches state for [entityIds] in parallel and writes successful results into the cache. + * Bounded by [CACHE_WARM_TIMEOUT_MS] so slow networks can't exceed the Wear tile render + * timeout — entities whose fetch hasn't landed in time simply stay missing from the cache + * and render with their domain-default icon. + */ + private suspend fun warmEntityCache(entityIds: List) { + val repo = serverManager.integrationRepository() + try { + withTimeoutOrNull(CACHE_WARM_TIMEOUT_MS) { + coroutineScope { + entityIds.map { id -> + async { + runCatching { repo.getEntity(id) }.getOrNull()?.let { entity -> + entityStates[entity.entityId] = entity + } + } + }.awaitAll() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Failed to warm entity cache") + } } - fun layout(entities: List, showLabels: Boolean): LayoutElement = Column.Builder().apply { - if (entities.isEmpty()) { - addContent( - LayoutElementBuilders.Text.Builder() - .setText(getString(commonR.string.shortcuts_tile_empty)) + private fun buildIconResource( + icon: com.mikepenz.iconics.typeface.IIcon, + iconSizeDp: Float, + iconSizePx: Int, + ): ResourceBuilders.ImageResource { + val bitmap = IconicsDrawable(this, icon).apply { + colorInt = Color.WHITE + sizeDp = iconSizeDp.roundToInt() + backgroundColor = IconicsColor.colorRes(R.color.colorOverlay) + }.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565) + val data = ByteBuffer.allocate(bitmap.byteCount).apply { + bitmap.copyPixelsToBuffer(this) + }.array() + return ResourceBuilders.ImageResource.Builder() + .setInlineResource( + ResourceBuilders.InlineImageResource.Builder() + .setData(data) + .setWidthPx(iconSizePx) + .setHeightPx(iconSizePx) + .setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565) .build(), - ) - } else { - addContent(rowLayout(entities.subList(0, min(2, entities.size)), showLabels)) - if (entities.size > 2) { - addContent(Spacer.Builder().setHeight(dp(SPACING)).build()) - addContent(rowLayout(entities.subList(2, min(5, entities.size)), showLabels)) - } - if (entities.size > 5) { - addContent(Spacer.Builder().setHeight(dp(SPACING)).build()) - addContent(rowLayout(entities.subList(5, min(7, entities.size)), showLabels)) + ).build() + } + + internal fun layout(entities: List, showLabels: Boolean, snapshot: TileSnapshot): LayoutElement = + Column.Builder().apply { + if (entities.isEmpty()) { + addContent( + LayoutElementBuilders.Text.Builder() + .setText(getString(commonR.string.shortcuts_tile_empty)) + .build(), + ) + } else { + addContent(rowLayout(entities.subList(0, min(2, entities.size)), showLabels, snapshot)) + if (entities.size > 2) { + addContent(Spacer.Builder().setHeight(dp(SPACING)).build()) + addContent(rowLayout(entities.subList(2, min(5, entities.size)), showLabels, snapshot)) + } + if (entities.size > 5) { + addContent(Spacer.Builder().setHeight(dp(SPACING)).build()) + addContent(rowLayout(entities.subList(5, min(7, entities.size)), showLabels, snapshot)) + } } } - } - .build() + .build() - private fun rowLayout(entities: List, showLabels: Boolean): LayoutElement = Row.Builder().apply { - addContent(iconLayout(entities[0], showLabels)) + private fun rowLayout( + entities: List, + showLabels: Boolean, + snapshot: TileSnapshot, + ): LayoutElement = Row.Builder().apply { + addContent(iconLayout(entities[0], showLabels, snapshot)) entities.drop(1).forEach { entity -> addContent(Spacer.Builder().setWidth(dp(SPACING)).build()) - addContent(iconLayout(entity, showLabels)) + addContent(iconLayout(entity, showLabels, snapshot)) } } .build() - private fun iconLayout(entity: SimplifiedEntity, showLabels: Boolean): LayoutElement = Box.Builder().apply { - val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL - setWidth(dp(CIRCLE_SIZE)) - setHeight(dp(CIRCLE_SIZE)) - setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER) - setModifiers( - ModifiersBuilders.Modifiers.Builder() - // Set circular background - .setBackground( - ModifiersBuilders.Background.Builder() - .setColor(argb(ContextCompat.getColor(baseContext, R.color.colorOverlay))) - .setCorner( - ModifiersBuilders.Corner.Builder() - .setRadius(dp(CIRCLE_SIZE / 2)) - .build(), - ) - .build(), - ) - // Make clickable and call activity - .setClickable( - ModifiersBuilders.Clickable.Builder() - .setId(entity.entityId) - .setOnClick( - ActionBuilders.LoadAction.Builder().build(), - ) - .build(), - ) - .build(), - ) - addContent( - // Add icon - LayoutElementBuilders.Image.Builder() - .setResourceId(entity.entityId) - .setWidth(dp(iconSize)) - .setHeight(dp(iconSize)) - .build(), - ) - if (showLabels) { - addContent( - LayoutElementBuilders.Arc.Builder() - .addContent( - LayoutElementBuilders.ArcText.Builder() - .setText(entity.friendlyName) - .setFontStyle( - LayoutElementBuilders.FontStyle.Builder() - .setSize(sp(TEXT_SIZE)) + private fun iconLayout(entity: SimplifiedEntity, showLabels: Boolean, snapshot: TileSnapshot): LayoutElement = + Box.Builder().apply { + val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL + val resourceId = entity.resourceIdIn(snapshot) + setWidth(dp(CIRCLE_SIZE)) + setHeight(dp(CIRCLE_SIZE)) + setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER) + setModifiers( + ModifiersBuilders.Modifiers.Builder() + .setBackground( + ModifiersBuilders.Background.Builder() + .setColor(argb(ContextCompat.getColor(baseContext, R.color.colorOverlay))) + .setCorner( + ModifiersBuilders.Corner.Builder() + .setRadius(dp(CIRCLE_SIZE / 2)) .build(), ) .build(), ) - .setModifiers( - ModifiersBuilders.Modifiers.Builder() - .setPadding( - ModifiersBuilders.Padding.Builder() - .setAll(dp(TEXT_PADDING)) - .build(), - ).build(), + .setClickable( + ModifiersBuilders.Clickable.Builder() + .setId(entity.entityId) + .setOnClick( + ActionBuilders.LoadAction.Builder().build(), + ) + .build(), ) .build(), ) + addContent( + LayoutElementBuilders.Image.Builder() + .setResourceId(resourceId) + .setWidth(dp(iconSize)) + .setHeight(dp(iconSize)) + .build(), + ) + if (showLabels) { + addContent( + LayoutElementBuilders.Arc.Builder() + .addContent( + LayoutElementBuilders.ArcText.Builder() + .setText(entity.friendlyName) + .setFontStyle( + LayoutElementBuilders.FontStyle.Builder() + .setSize(sp(TEXT_SIZE)) + .build(), + ) + .build(), + ) + .setModifiers( + ModifiersBuilders.Modifiers.Builder() + .setPadding( + ModifiersBuilders.Padding.Builder() + .setAll(dp(TEXT_PADDING)) + .build(), + ).build(), + ) + .build(), + ) + } } - } - .build() + .build() companion object { + private const val TAG = "ShortcutsTile" + + /** + * Entity cache shared across service instances. Populated by the WebSocket subscription + * while a tile is visible, warmed on cold start by bounded parallel REST fetches, and + * updated optimistically on tap. Read-only on the tile render path. + */ + private val entityStates = ConcurrentHashMap() + + /** + * Snapshots of [entityStates] keyed by the resources version issued in `onTileRequest`. + * Ensures the matching `onTileResourcesRequest` renders bitmaps for the same state the + * layout referenced, even if the live cache mutated in between. + */ + private val snapshotStash = SnapshotStash() + + /** WebSocket subscription for entity state changes. Outlives service instances. */ + private var subscriptionJob: Job? = null + private val subscriptionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + fun requestUpdate(context: Context) { getUpdater(context).requestUpdate(ShortcutsTile::class.java) } + + private fun startEntitySubscription(serverManager: ServerManager, entityIds: List, context: Context) { + if (subscriptionJob?.isActive == true) return + val appContext = context.applicationContext + subscriptionJob = subscriptionScope.launch { + try { + val flow = serverManager.integrationRepository().getEntityUpdates(entityIds) ?: return@launch + flow.collect { entity -> + entityStates[entity.entityId] = entity + requestUpdate(appContext) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Entity subscription failed") + } + } + } + + private fun stopEntitySubscription() { + subscriptionJob?.cancel() + subscriptionJob = null + } } } diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt new file mode 100644 index 00000000000..1c07451c014 --- /dev/null +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt @@ -0,0 +1,128 @@ +package io.homeassistant.companion.android.tiles + +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.data.SimplifiedEntity + +/** + * Pairs of reciprocal states that a toggle tap is expected to swap between. If the entity's + * current state matches one side of a pair for its domain, the tap is predicted to produce + * the other side. + * + * Why: `requestUpdate()` after a WebSocket state change does not reliably trigger the Wear + * tile framework to re-render, so users see the previous state until an unrelated event + * forces a refresh. Predicting the post-click state in `onTileRequest` makes the tap feel + * immediate. If the server ends up in a different state (e.g. a lock stuck mid-transition), + * the subscription will overwrite the cache and the display converges next refresh. + */ +private val TOGGLE_STATE_PAIRS: Map> = mapOf( + "switch" to ("on" to "off"), + "light" to ("on" to "off"), + "input_boolean" to ("on" to "off"), + "fan" to ("on" to "off"), + "lock" to ("locked" to "unlocked"), + "cover" to ("open" to "closed"), +) + +/** + * Returns the state an entity is expected to be in after a single click, or null if the + * domain isn't a known toggle or the current state isn't a recognized side of its pair. + * Conservative on purpose: unknown inputs leave the cache alone. + */ +internal fun predictedStateAfterClick(entity: Entity): String? { + val pair = TOGGLE_STATE_PAIRS[entity.domain] ?: return null + return when (entity.state) { + pair.first -> pair.second + pair.second -> pair.first + else -> null + } +} + +/** + * Returns a copy of [entity] with its state flipped per [predictedStateAfterClick], or the + * input unchanged if no prediction is possible. + */ +internal fun applyOptimisticClick(entity: Entity): Entity { + val predicted = predictedStateAfterClick(entity) ?: return entity + return entity.copy(state = predicted) +} + +/** + * Frozen view of entity states used to render a single tile response. + * + * `onTileRequest` and `onTileResourcesRequest` are separate framework calls that both need + * a consistent view of entity state. If each reads the live cache independently, a WebSocket + * update in between can cause the layout to reference a resource ID that the resource bundle + * never generated — producing stale or missing bitmaps on screen. + * + * A snapshot is built at `onTileRequest` time, stashed by resources version, and looked up + * again when the matching `onTileResourcesRequest` arrives. + */ +internal data class TileSnapshot(val entityStates: Map) { + /** State string for an entity, or null if the cache has no entry. */ + fun stateOf(entityId: String): String? = entityStates[entityId]?.state + + fun entityOf(entityId: String): Entity? = entityStates[entityId] + + companion object { + /** + * Take a snapshot from a live cache, copying values for the given entity IDs. + * Missing entries are included as null so the snapshot is complete for the tile. + */ + fun from(liveCache: Map, entityIds: Iterable): TileSnapshot = + TileSnapshot(entityIds.associateWith { liveCache[it] }) + } +} + +/** + * Resource ID used in the tile layout. The state suffix ensures Wear OS treats different + * states as distinct resources, preventing the bitmap cache from serving a stale icon when + * the state changes. + */ +internal fun resourceIdForEntity(entityId: String, state: String?): String = + if (state.isNullOrEmpty()) entityId else "$entityId@$state" + +/** Resource ID for an entity in the given snapshot. */ +internal fun SimplifiedEntity.resourceIdIn(snapshot: TileSnapshot): String = + resourceIdForEntity(entityId, snapshot.stateOf(entityId)) + +/** + * The set of resource IDs required by a tile layout for the given entities and snapshot. + * Used as the invariant: every ID the layout references must be present in the resource bundle. + */ +internal fun requiredResourceIds(entities: List, snapshot: TileSnapshot): Set = + entities.map { it.resourceIdIn(snapshot) }.toSet() + +/** + * Bounded cache of snapshots keyed by resources version string. + * + * Wear OS may request resources for an older version than the latest tile render, especially + * when multiple `requestUpdate()` calls arrive quickly. Keeping the last few snapshots lets + * those requests resolve to the correct frozen state instead of the current live cache. + */ +internal class SnapshotStash(private val maxEntries: Int = 4) { + private val entries = linkedMapOf() + + @Synchronized + fun put(version: String, snapshot: TileSnapshot) { + entries.remove(version) + entries[version] = snapshot + while (entries.size > maxEntries) { + val oldest = entries.keys.iterator() + if (oldest.hasNext()) { + oldest.next() + oldest.remove() + } + } + } + + @Synchronized + fun get(version: String): TileSnapshot? = entries[version] + + @Synchronized + fun clear() { + entries.clear() + } + + @Synchronized + fun size(): Int = entries.size +} diff --git a/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt b/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt new file mode 100644 index 00000000000..08e3d2695f4 --- /dev/null +++ b/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt @@ -0,0 +1,271 @@ +package io.homeassistant.companion.android.tiles + +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.data.SimplifiedEntity +import java.time.LocalDateTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ShortcutsTileStateTest { + + private val epoch = LocalDateTime.of(2026, 1, 1, 0, 0) + + private fun entity(id: String, state: String): Entity = Entity( + entityId = id, + state = state, + attributes = emptyMap(), + lastChanged = epoch, + lastUpdated = epoch, + ) + + @Test + fun `resourceIdForEntity omits separator when state is null`() { + assertEquals("switch.plug_a", resourceIdForEntity("switch.plug_a", null)) + } + + @Test + fun `resourceIdForEntity omits separator when state is empty`() { + assertEquals("switch.plug_a", resourceIdForEntity("switch.plug_a", "")) + } + + @Test + fun `resourceIdForEntity includes state suffix for distinct states`() { + val on = resourceIdForEntity("switch.plug_a", "on") + val off = resourceIdForEntity("switch.plug_a", "off") + assertEquals("switch.plug_a@on", on) + assertEquals("switch.plug_a@off", off) + assertNotEquals(on, off) + } + + @Test + fun `snapshot includes every requested entity id`() { + val cache = mapOf( + "switch.plug_a" to entity("switch.plug_a", "on"), + "lock.front" to entity("lock.front", "locked"), + ) + val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a", "lock.front", "light.missing")) + + assertEquals("on", snapshot.stateOf("switch.plug_a")) + assertEquals("locked", snapshot.stateOf("lock.front")) + assertNull(snapshot.stateOf("light.missing")) + } + + @Test + fun `snapshot is decoupled from later cache mutations`() { + val cache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on")) + val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a")) + + cache["switch.plug_a"] = entity("switch.plug_a", "off") + + assertEquals("on", snapshot.stateOf("switch.plug_a")) + } + + @Test + fun `requiredResourceIds matches resource ids produced by iconLayout inputs`() { + val entities = listOf( + SimplifiedEntity(entityId = "switch.plug_a"), + SimplifiedEntity(entityId = "lock.front"), + ) + val snapshot = TileSnapshot.from( + mapOf( + "switch.plug_a" to entity("switch.plug_a", "on"), + "lock.front" to entity("lock.front", "locked"), + ), + entities.map { it.entityId }, + ) + + val ids = requiredResourceIds(entities, snapshot) + + assertTrue("switch.plug_a@on" in ids) + assertTrue("lock.front@locked" in ids) + assertEquals(2, ids.size) + } + + @Test + fun `requiredResourceIds differs when only one entity state changes between snapshots`() { + val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a"), SimplifiedEntity(entityId = "lock.front")) + + val s1 = TileSnapshot.from( + mapOf( + "switch.plug_a" to entity("switch.plug_a", "on"), + "lock.front" to entity("lock.front", "locked"), + ), + entities.map { it.entityId }, + ) + val s2 = TileSnapshot.from( + mapOf( + "switch.plug_a" to entity("switch.plug_a", "off"), + "lock.front" to entity("lock.front", "locked"), + ), + entities.map { it.entityId }, + ) + + assertNotEquals(requiredResourceIds(entities, s1), requiredResourceIds(entities, s2)) + } + + @Test + fun `snapshot stash returns the exact snapshot that was stored`() { + val stash = SnapshotStash() + val snapshot = TileSnapshot.from(mapOf("a" to entity("a", "1")), listOf("a")) + stash.put("v1", snapshot) + + assertSame(snapshot, stash.get("v1")) + } + + @Test + fun `snapshot stash is resistant to live cache mutation between put and get`() { + val stash = SnapshotStash() + val cache = mutableMapOf("a" to entity("a", "1")) + val snapshot = TileSnapshot.from(cache, listOf("a")) + stash.put("v1", snapshot) + + cache["a"] = entity("a", "2") + val retrieved = stash.get("v1") + + assertEquals("1", retrieved?.stateOf("a")) + } + + @Test + fun `snapshot stash evicts oldest entries past the bound`() { + val stash = SnapshotStash(maxEntries = 2) + stash.put("v1", TileSnapshot.from(emptyMap(), emptyList())) + stash.put("v2", TileSnapshot.from(emptyMap(), emptyList())) + stash.put("v3", TileSnapshot.from(emptyMap(), emptyList())) + + assertNull(stash.get("v1")) + assertEquals(2, stash.size()) + } + + @Test + fun `snapshot stash clear removes all entries`() { + val stash = SnapshotStash() + stash.put("v1", TileSnapshot.from(emptyMap(), emptyList())) + stash.put("v2", TileSnapshot.from(emptyMap(), emptyList())) + stash.clear() + + assertNull(stash.get("v1")) + assertNull(stash.get("v2")) + } + + @Test + fun `put with existing version replaces snapshot and keeps size bounded`() { + val stash = SnapshotStash(maxEntries = 2) + val first = TileSnapshot.from(mapOf("a" to entity("a", "1")), listOf("a")) + val second = TileSnapshot.from(mapOf("a" to entity("a", "2")), listOf("a")) + + stash.put("v1", first) + stash.put("v1", second) + + assertSame(second, stash.get("v1")) + assertEquals(1, stash.size()) + } + + /** + * Mimics the runtime race: tile request builds layout from snapshot S1, WebSocket update + * mutates cache to S2 before resources request arrives. Invariant: the layout's required + * resource IDs must all be satisfiable from the stashed snapshot, regardless of current cache. + */ + @Test + fun `mutation between tile request and resources request does not break invariant`() { + val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a")) + val liveCache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on")) + val stash = SnapshotStash() + + val versionA = "v-1" + val snapshotA = TileSnapshot.from(liveCache, entities.map { it.entityId }) + stash.put(versionA, snapshotA) + val layoutIds = requiredResourceIds(entities, snapshotA) + + liveCache["switch.plug_a"] = entity("switch.plug_a", "off") + + val retrievedSnapshot = stash.get(versionA)!! + val resourceIds = entities.map { it.resourceIdIn(retrievedSnapshot) }.toSet() + + assertEquals(layoutIds, resourceIds) + assertTrue("switch.plug_a@on" in resourceIds) + } + + @Test + fun `predictedStateAfterClick flips switch between on and off`() { + assertEquals("off", predictedStateAfterClick(entity("switch.plug_a", "on"))) + assertEquals("on", predictedStateAfterClick(entity("switch.plug_a", "off"))) + } + + @Test + fun `predictedStateAfterClick flips light between on and off`() { + assertEquals("off", predictedStateAfterClick(entity("light.living_room", "on"))) + assertEquals("on", predictedStateAfterClick(entity("light.living_room", "off"))) + } + + @Test + fun `predictedStateAfterClick flips lock between locked and unlocked`() { + assertEquals("unlocked", predictedStateAfterClick(entity("lock.front", "locked"))) + assertEquals("locked", predictedStateAfterClick(entity("lock.front", "unlocked"))) + } + + @Test + fun `predictedStateAfterClick flips cover between open and closed`() { + assertEquals("closed", predictedStateAfterClick(entity("cover.garage", "open"))) + assertEquals("open", predictedStateAfterClick(entity("cover.garage", "closed"))) + } + + @Test + fun `predictedStateAfterClick flips input_boolean and fan`() { + assertEquals("off", predictedStateAfterClick(entity("input_boolean.flag", "on"))) + assertEquals("on", predictedStateAfterClick(entity("fan.bedroom", "off"))) + } + + @Test + fun `predictedStateAfterClick returns null for unknown domain`() { + assertNull(predictedStateAfterClick(entity("sensor.temperature", "on"))) + assertNull(predictedStateAfterClick(entity("climate.hvac", "heat"))) + } + + @Test + fun `predictedStateAfterClick returns null for unrecognized state`() { + assertNull(predictedStateAfterClick(entity("lock.front", "jammed"))) + assertNull(predictedStateAfterClick(entity("switch.plug_a", "unavailable"))) + } + + @Test + fun `applyOptimisticClick returns new entity with flipped state for toggles`() { + val before = entity("switch.plug_a", "on") + val after = applyOptimisticClick(before) + assertEquals("off", after.state) + assertNotEquals(before.state, after.state) + } + + @Test + fun `applyOptimisticClick returns input unchanged when state is not predictable`() { + val before = entity("sensor.temperature", "22.5") + val after = applyOptimisticClick(before) + assertSame(before, after) + } + + @Test + fun `applyOptimisticClick returns input unchanged for unrecognized state of known domain`() { + val before = entity("lock.front", "jammed") + val after = applyOptimisticClick(before) + assertSame(before, after) + } + + /** + * End-to-end contract: after a click, a snapshot built from the optimistically-updated + * cache must reference the predicted resource ID — that's what fixes the "display lags + * reality by one tap" symptom. + */ + @Test + fun `snapshot after optimistic click points to predicted resource id`() { + val cache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on")) + cache["switch.plug_a"] = applyOptimisticClick(cache["switch.plug_a"]!!) + + val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a")) + val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a")) + + assertTrue("switch.plug_a@off" in requiredResourceIds(entities, snapshot)) + } +}