feat(ui): complete ABox app experience redesign (#10)
## Summary - rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience - redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content - add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative - add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes - persist real activity events for device commands, infrared actions, and scene execution - stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option - update launcher icons and bilingual application name ## Verification - `:android:app:assembleDebug` - installed the debug APK on `emulator-5554` - manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions ## Notes - no automated test suite was run during the visual iteration cycle - `docs/superpowers/**` and local reference repositories are excluded from this PR Reviewed-on: #10 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@JvmInline
|
||||
value class ActivityEventId(val value: String)
|
||||
|
||||
enum class ActivityEventKind {
|
||||
SESSION_SIGNED_IN,
|
||||
SESSION_SIGNED_OUT,
|
||||
SOCKET_COMMAND,
|
||||
SOCKET_CONFIRMATION,
|
||||
SENSOR_REFRESH,
|
||||
INFRARED_SEND,
|
||||
INFRARED_LEARN,
|
||||
SCENE_EXECUTION,
|
||||
SCENE_ACTION,
|
||||
}
|
||||
|
||||
enum class ActivitySource { USER, AUTOMATION, SYSTEM }
|
||||
|
||||
enum class ActivityOutcome {
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
AWAITING_CONFIRMATION,
|
||||
}
|
||||
|
||||
enum class ActivityTargetType { PROFILE, DEVICE, SCENE, SCENE_ACTION, REMOTE, INFRARED_KEY }
|
||||
|
||||
data class ActivityTarget(
|
||||
val type: ActivityTargetType,
|
||||
val stableId: String,
|
||||
val displayName: String,
|
||||
)
|
||||
|
||||
data class ActivityEvent(
|
||||
val id: ActivityEventId,
|
||||
val profileId: ProviderProfileId,
|
||||
val occurredAtEpochMillis: Long,
|
||||
val kind: ActivityEventKind,
|
||||
val source: ActivitySource,
|
||||
val outcome: ActivityOutcome,
|
||||
val target: ActivityTarget,
|
||||
val errorCode: String? = null,
|
||||
)
|
||||
|
||||
data class ActivityEventCursor(
|
||||
val occurredAtEpochMillis: Long,
|
||||
val id: ActivityEventId,
|
||||
) : Comparable<ActivityEventCursor> {
|
||||
override fun compareTo(other: ActivityEventCursor): Int {
|
||||
val timeComparison = occurredAtEpochMillis.compareTo(other.occurredAtEpochMillis)
|
||||
return if (timeComparison != 0) timeComparison else id.value.compareTo(other.id.value)
|
||||
}
|
||||
}
|
||||
|
||||
interface ActivityRepository {
|
||||
fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>>
|
||||
|
||||
suspend fun loadBefore(
|
||||
profileId: ProviderProfileId,
|
||||
before: ActivityEventCursor,
|
||||
limit: Int,
|
||||
): DomainResult<List<ActivityEvent>>
|
||||
|
||||
suspend fun append(event: ActivityEvent): DomainResult<Unit>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ActivityEventWriter(
|
||||
private val repository: ActivityRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
fun recordDevice(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
kind: ActivityEventKind,
|
||||
outcome: ActivityOutcome,
|
||||
errorCode: String? = null,
|
||||
source: ActivitySource = ActivitySource.USER,
|
||||
) {
|
||||
record(
|
||||
profileId = profileId,
|
||||
kind = kind,
|
||||
source = source,
|
||||
outcome = outcome,
|
||||
target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value),
|
||||
errorCode = errorCode,
|
||||
)
|
||||
}
|
||||
|
||||
fun record(
|
||||
profileId: ProviderProfileId,
|
||||
kind: ActivityEventKind,
|
||||
source: ActivitySource,
|
||||
outcome: ActivityOutcome,
|
||||
target: ActivityTarget,
|
||||
errorCode: String? = null,
|
||||
) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
repository.append(
|
||||
ActivityEvent(
|
||||
id = ActivityEventId(UUID.randomUUID().toString()),
|
||||
profileId = profileId,
|
||||
occurredAtEpochMillis = System.currentTimeMillis(),
|
||||
kind = kind,
|
||||
source = source,
|
||||
outcome = outcome,
|
||||
target = target,
|
||||
errorCode = errorCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
interface AutomationClock {
|
||||
fun nowEpochMillis(): Long
|
||||
}
|
||||
|
||||
data class AutomationSnapshot(
|
||||
val observedAtEpochMillis: Long,
|
||||
val readings: Map<DeviceName, DomainResult<TemperatureHumidityReading>> = emptyMap(),
|
||||
val socketStatuses: Map<DeviceName, DomainResult<SocketStatus>> = emptyMap(),
|
||||
)
|
||||
|
||||
sealed interface TriggerEvaluation {
|
||||
data class Matched(val occurrenceKey: String) : TriggerEvaluation
|
||||
data object NotMatched : TriggerEvaluation
|
||||
data class Unavailable(val error: DomainError) : TriggerEvaluation
|
||||
}
|
||||
|
||||
sealed interface ConditionsEvaluation {
|
||||
data object Satisfied : ConditionsEvaluation
|
||||
data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation
|
||||
data class Unavailable(val error: DomainError) : ConditionsEvaluation
|
||||
}
|
||||
|
||||
class SceneTriggerEvaluator {
|
||||
fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation {
|
||||
if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched
|
||||
val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) }
|
||||
return when (matched) {
|
||||
is TriggerEvaluation.Matched -> matched
|
||||
is TriggerEvaluation.Unavailable -> matched
|
||||
is TriggerEvaluation.NotMatched -> matched
|
||||
null -> TriggerEvaluation.NotMatched
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
|
||||
is SceneTrigger.Time -> {
|
||||
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
|
||||
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek
|
||||
if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) {
|
||||
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
||||
} else null
|
||||
}
|
||||
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
|
||||
it.temperature
|
||||
}
|
||||
is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) {
|
||||
it.humidity
|
||||
}
|
||||
is SceneTrigger.DeviceState -> {
|
||||
when (val result = snapshot.socketStatuses[trigger.deviceName]) {
|
||||
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
|
||||
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
|
||||
is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) {
|
||||
TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}")
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun threshold(
|
||||
result: DomainResult<TemperatureHumidityReading>?,
|
||||
operator: ComparisonOperator,
|
||||
threshold: Double,
|
||||
value: (TemperatureHumidityReading) -> String?,
|
||||
): TriggerEvaluation? {
|
||||
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
|
||||
return when (result) {
|
||||
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
||||
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
|
||||
is DomainResult.Success -> {
|
||||
val actual = value(result.value)?.toDoubleOrNull()
|
||||
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
||||
if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
|
||||
ComparisonOperator.GREATER_THAN -> actual > threshold
|
||||
ComparisonOperator.LESS_THAN -> actual < threshold
|
||||
ComparisonOperator.EQUALS -> actual == threshold
|
||||
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
|
||||
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
|
||||
}
|
||||
|
||||
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
|
||||
}
|
||||
|
||||
class SceneConditionEvaluator {
|
||||
fun evaluate(conditions: List<SceneCondition>, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation {
|
||||
for (condition in conditions) {
|
||||
when (val result = evaluateOne(condition, snapshot, now)) {
|
||||
ConditionsEvaluation.Satisfied -> Unit
|
||||
else -> return result
|
||||
}
|
||||
}
|
||||
return ConditionsEvaluation.Satisfied
|
||||
}
|
||||
|
||||
private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) {
|
||||
is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature }
|
||||
is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity }
|
||||
is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) {
|
||||
null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
|
||||
is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error)
|
||||
is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
|
||||
}
|
||||
is SceneCondition.TimeRange -> {
|
||||
val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start"))
|
||||
val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end"))
|
||||
val current = now.toLocalTime()
|
||||
val inside = if (start <= end) current in start..end else current >= start || current <= end
|
||||
if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun metricCondition(
|
||||
condition: SceneCondition,
|
||||
result: DomainResult<TemperatureHumidityReading>?,
|
||||
operator: ComparisonOperator,
|
||||
threshold: Double,
|
||||
value: (TemperatureHumidityReading) -> String?,
|
||||
): ConditionsEvaluation {
|
||||
if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
|
||||
val actual = when (result) {
|
||||
null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
||||
is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error)
|
||||
is DomainResult.Success -> value(result.value)?.toDoubleOrNull()
|
||||
?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
||||
}
|
||||
return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
|
||||
}
|
||||
|
||||
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
|
||||
ComparisonOperator.GREATER_THAN -> actual > threshold
|
||||
ComparisonOperator.LESS_THAN -> actual < threshold
|
||||
ComparisonOperator.EQUALS -> actual == threshold
|
||||
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
|
||||
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
|
||||
}
|
||||
|
||||
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* Production implementation of [SceneExecutor].
|
||||
@@ -24,6 +26,50 @@ class DefaultSceneExecutor(
|
||||
) : SceneExecutor {
|
||||
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
|
||||
|
||||
override fun execute(
|
||||
scene: Scene,
|
||||
executionId: SceneExecutionId,
|
||||
source: SceneExecutionSource,
|
||||
): Flow<SceneExecutionUpdate> = flow {
|
||||
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
|
||||
if (alreadyRunning) {
|
||||
emit(SceneExecutionUpdate.Rejected(DomainError.OperationFailed(detail = "Scene is already running")))
|
||||
return@flow
|
||||
}
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
emit(SceneExecutionUpdate.Started(executionId, scene))
|
||||
val results = mutableListOf<SceneActionExecutionResult>()
|
||||
var aborted = false
|
||||
for (action in scene.actions) {
|
||||
if (aborted) {
|
||||
val skipped = SceneActionExecutionResult(action, SceneActionOutcome.SKIPPED, null)
|
||||
results += skipped
|
||||
emit(SceneExecutionUpdate.StepFinished(skipped))
|
||||
continue
|
||||
}
|
||||
emit(SceneExecutionUpdate.StepPending(action))
|
||||
emit(SceneExecutionUpdate.StepRunning(action))
|
||||
val started = System.currentTimeMillis()
|
||||
val result = executeAction(scene.profileId, action).copy(
|
||||
executionDurationMillis = System.currentTimeMillis() - started,
|
||||
)
|
||||
results += result
|
||||
emit(SceneExecutionUpdate.StepFinished(result))
|
||||
if (result.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||
aborted = true
|
||||
}
|
||||
}
|
||||
emit(
|
||||
SceneExecutionUpdate.Completed(
|
||||
SceneExecutionResult(scene, results, System.currentTimeMillis() - startTime),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
runningScenes.remove(scene.id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
|
||||
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
|
||||
if (alreadyRunning) {
|
||||
|
||||
@@ -63,6 +63,15 @@ interface SocketGateway {
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<SocketStatus>
|
||||
|
||||
suspend fun commandPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<Unit> = when (val result = setPower(profileId, deviceName, powerState)) {
|
||||
is DomainResult.Success -> DomainResult.Success(Unit)
|
||||
is DomainResult.Failure -> result
|
||||
}
|
||||
|
||||
suspend fun setPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
+47
-14
@@ -92,8 +92,7 @@ class InMemorySceneRepository : SceneRepository {
|
||||
|
||||
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
|
||||
flows.getOrPut(profileId) {
|
||||
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList()
|
||||
MutableStateFlow(DomainResult.Success(initial))
|
||||
MutableStateFlow(DomainResult.Success(defaultScenes(profileId)))
|
||||
}
|
||||
|
||||
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
|
||||
@@ -107,12 +106,30 @@ class InMemorySceneRepository : SceneRepository {
|
||||
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
|
||||
)
|
||||
|
||||
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
|
||||
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
|
||||
Scene(
|
||||
id = SceneId("${profileId.value}:scene-arriving-home"),
|
||||
profileId = profileId,
|
||||
name = "回家模式",
|
||||
description = "开启 Zigbee 插座控制器",
|
||||
type = AutomationType.MANUAL,
|
||||
enabled = true,
|
||||
icon = SceneIcon.HOME,
|
||||
color = SceneColor("#4CAF50"),
|
||||
actions = listOf(
|
||||
SceneAction.SocketPower(
|
||||
id = SceneActionId("${profileId.value}:scene-arriving-home:socket-1"),
|
||||
profileId = profileId,
|
||||
deviceName = DeviceName("Zigbee插座控制器"),
|
||||
powerState = SocketPowerState.ON,
|
||||
),
|
||||
),
|
||||
),
|
||||
Scene(
|
||||
id = SceneId("${profileId.value}:scene-leaving-home"),
|
||||
profileId = profileId,
|
||||
name = "离家模式",
|
||||
description = "关闭插座并延时关闭电视",
|
||||
description = "关闭 Zigbee 插座控制器",
|
||||
type = AutomationType.MANUAL,
|
||||
enabled = true,
|
||||
icon = SceneIcon.AWAY,
|
||||
@@ -124,16 +141,32 @@ class InMemorySceneRepository : SceneRepository {
|
||||
deviceName = DeviceName("Zigbee插座控制器"),
|
||||
powerState = SocketPowerState.OFF,
|
||||
),
|
||||
SceneAction.Delay(
|
||||
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
|
||||
profileId = profileId,
|
||||
durationSeconds = 1,
|
||||
),
|
||||
),
|
||||
Scene(
|
||||
id = SceneId("${profileId.value}:scene-temperature-alert"),
|
||||
profileId = profileId,
|
||||
name = "高温提醒",
|
||||
description = "室温高于 28°C 时发送提醒",
|
||||
type = AutomationType.AUTOMATIC,
|
||||
enabled = true,
|
||||
icon = SceneIcon.THERMOSTAT,
|
||||
color = SceneColor("#EF5350"),
|
||||
triggers = listOf(
|
||||
SceneTrigger.Temperature(
|
||||
id = SceneTriggerId("${profileId.value}:trigger-temp"),
|
||||
deviceName = DeviceName("温湿度传感器"),
|
||||
operator = ComparisonOperator.GREATER_THAN,
|
||||
thresholdCelsius = 28.0,
|
||||
label = "温度高于 28°C",
|
||||
),
|
||||
SceneAction.InfraredKey(
|
||||
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
|
||||
),
|
||||
actions = listOf(
|
||||
SceneAction.Notification(
|
||||
id = SceneActionId("${profileId.value}:scene-temperature-alert:notification-1"),
|
||||
profileId = profileId,
|
||||
deviceName = DeviceName("红外控制器"),
|
||||
key = "1",
|
||||
title = "温度提醒",
|
||||
message = "当前室温已高于 28°C",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -148,7 +181,7 @@ class InMemorySceneRepository : SceneRepository {
|
||||
color = SceneColor("#26A69A"),
|
||||
triggers = listOf(
|
||||
SceneTrigger.Temperature(
|
||||
id = SceneTriggerId("${profileId.value}:trigger-temp"),
|
||||
id = SceneTriggerId("${profileId.value}:trigger-cooling-temp"),
|
||||
deviceName = DeviceName("温湿度传感器"),
|
||||
operator = ComparisonOperator.GREATER_THAN,
|
||||
thresholdCelsius = 28.0,
|
||||
@@ -157,7 +190,7 @@ class InMemorySceneRepository : SceneRepository {
|
||||
),
|
||||
conditions = listOf(
|
||||
SceneCondition.SocketPower(
|
||||
id = SceneConditionId("${profileId.value}:cond-socket"),
|
||||
id = SceneConditionId("${profileId.value}:cond-cooling-socket"),
|
||||
deviceName = DeviceName("Zigbee插座控制器"),
|
||||
expectedState = SocketPowerState.OFF,
|
||||
),
|
||||
|
||||
@@ -83,8 +83,8 @@ data class SocketStatus(
|
||||
data class TemperatureHumidityReading(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
val temperature: String,
|
||||
val humidity: String,
|
||||
val temperature: String?,
|
||||
val humidity: String?,
|
||||
)
|
||||
|
||||
data class InfraredCode(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
interface NotificationGateway {
|
||||
suspend fun show(
|
||||
profileId: ProviderProfileId,
|
||||
title: String,
|
||||
message: String,
|
||||
): DomainResult<Unit>
|
||||
}
|
||||
@@ -62,7 +62,7 @@ sealed interface SceneTrigger {
|
||||
data class DeviceState(
|
||||
override val id: SceneTriggerId,
|
||||
val deviceName: DeviceName,
|
||||
val expectedState: String, // e.g. "ON" / "OFF"
|
||||
val expectedState: SocketPowerState,
|
||||
val label: String = "",
|
||||
) : SceneTrigger
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
@JvmInline
|
||||
value class SceneExecutionId(val value: String)
|
||||
|
||||
enum class SceneExecutionSource { USER, AUTOMATION }
|
||||
|
||||
sealed interface SceneExecutionUpdate {
|
||||
data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate
|
||||
data class StepPending(val action: SceneAction) : SceneExecutionUpdate
|
||||
data class StepRunning(val action: SceneAction) : SceneExecutionUpdate
|
||||
data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate
|
||||
data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate
|
||||
data class Rejected(val error: DomainError) : SceneExecutionUpdate
|
||||
}
|
||||
|
||||
enum class SceneActionOutcome {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
|
||||
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
|
||||
@@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain
|
||||
*/
|
||||
interface SceneExecutor {
|
||||
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
|
||||
|
||||
fun execute(
|
||||
scene: Scene,
|
||||
executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()),
|
||||
source: SceneExecutionSource = SceneExecutionSource.USER,
|
||||
): Flow<SceneExecutionUpdate> = flow {
|
||||
emit(SceneExecutionUpdate.Started(executionId, scene))
|
||||
when (val result = execute(scene)) {
|
||||
is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error))
|
||||
is DomainResult.Success -> {
|
||||
result.value.results.forEach { actionResult ->
|
||||
emit(SceneExecutionUpdate.StepPending(actionResult.action))
|
||||
emit(SceneExecutionUpdate.StepRunning(actionResult.action))
|
||||
emit(SceneExecutionUpdate.StepFinished(actionResult))
|
||||
}
|
||||
emit(SceneExecutionUpdate.Completed(result.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,35 @@ class SessionCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun endActiveSession(): DomainResult<Unit> = storeOperation {
|
||||
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
|
||||
when (val logoutResult = profile.gateway().logout(profile.id)) {
|
||||
is DomainResult.Failure -> return@storeOperation logoutResult
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
profileStore.setActiveProfile(null)
|
||||
} else {
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun forgetSavedCredentials(): DomainResult<Unit> = storeOperation {
|
||||
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
|
||||
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
|
||||
is DomainResult.Failure -> return@storeOperation credentialResult
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
val preferences = profileStore.loginPreferences(profile.id)
|
||||
profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false))
|
||||
}
|
||||
|
||||
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
|
||||
val profile = profileStore.activeProfile()
|
||||
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||
profile.gateway().validateSession(profile.id)
|
||||
}
|
||||
|
||||
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
||||
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
|
||||
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
|
||||
|
||||
+3
-11
@@ -23,24 +23,16 @@ class AddVirtualRemoteKey(
|
||||
}
|
||||
|
||||
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
|
||||
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
||||
// Find next available slot
|
||||
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
||||
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
||||
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
|
||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
|
||||
nextSlot.toString()
|
||||
} else null
|
||||
|
||||
val binding = RemoteKeyBinding(
|
||||
id = UUID.randomUUID().toString(),
|
||||
remoteId = remoteId,
|
||||
profileId = profileId,
|
||||
hardwareDeviceName = remote.deviceName,
|
||||
buttonId = buttonId,
|
||||
displayName = trimmed,
|
||||
assignedSlot = assignedSlot,
|
||||
assignedSlot = null,
|
||||
rawHexCode = null,
|
||||
status = InfraredKeyStatus.FAILED, // Initially unlearned
|
||||
status = InfraredKeyStatus.UNLEARNED,
|
||||
)
|
||||
|
||||
when (val res = repository.updateKeyBinding(binding)) {
|
||||
|
||||
+4
-2
@@ -64,11 +64,12 @@ class CreateVirtualRemote(
|
||||
id = UUID.randomUUID().toString(),
|
||||
remoteId = remoteId,
|
||||
profileId = profileId,
|
||||
hardwareDeviceName = deviceName,
|
||||
buttonId = template.buttonId,
|
||||
displayName = template.displayName,
|
||||
assignedSlot = (index + 1).toString(),
|
||||
rawHexCode = template.defaultHexCode,
|
||||
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
|
||||
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.UNLEARNED,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -77,6 +78,7 @@ class CreateVirtualRemote(
|
||||
id = UUID.randomUUID().toString(),
|
||||
remoteId = remoteId,
|
||||
profileId = profileId,
|
||||
hardwareDeviceName = deviceName,
|
||||
buttonId = template.buttonId,
|
||||
displayName = template.displayName,
|
||||
assignedSlot = null,
|
||||
@@ -84,7 +86,7 @@ class CreateVirtualRemote(
|
||||
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
InfraredKeyStatus.READY
|
||||
} else {
|
||||
InfraredKeyStatus.FAILED
|
||||
InfraredKeyStatus.UNLEARNED
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ data class InfraredKeyRef(
|
||||
)
|
||||
|
||||
enum class InfraredKeyStatus {
|
||||
UNLEARNED,
|
||||
LEARNING,
|
||||
READY,
|
||||
FAILED,
|
||||
|
||||
@@ -181,6 +181,7 @@ class RelearnInfraredKey(
|
||||
val result = repository.transitionStatus(
|
||||
ref = ref,
|
||||
expectedStatuses = setOf(
|
||||
InfraredKeyStatus.UNLEARNED,
|
||||
InfraredKeyStatus.READY,
|
||||
InfraredKeyStatus.FAILED,
|
||||
InfraredKeyStatus.LEARNING,
|
||||
|
||||
+12
-3
@@ -18,6 +18,11 @@ class LearnVirtualRemoteKey(
|
||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
||||
is DomainResult.Failure -> return res
|
||||
}
|
||||
if (remote.type != RemoteDeviceType.CUSTOM) {
|
||||
return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Only custom remotes support learning"),
|
||||
)
|
||||
}
|
||||
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
|
||||
is DomainResult.Success -> res.value
|
||||
is DomainResult.Failure -> return res
|
||||
@@ -26,9 +31,13 @@ class LearnVirtualRemoteKey(
|
||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found"))
|
||||
|
||||
val targetSlot = binding.assignedSlot ?: run {
|
||||
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
||||
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
||||
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
|
||||
val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) {
|
||||
is DomainResult.Success -> result.value
|
||||
is DomainResult.Failure -> return result
|
||||
}
|
||||
val nextSlot = quota.availableStaticSlots.firstOrNull() ?: return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"),
|
||||
)
|
||||
nextSlot.toString()
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -80,11 +80,12 @@ data class RemoteKeyBinding(
|
||||
val id: String,
|
||||
val remoteId: VirtualRemoteId,
|
||||
val profileId: ProviderProfileId,
|
||||
val hardwareDeviceName: DeviceName = DeviceName(""),
|
||||
val buttonId: String,
|
||||
val displayName: String,
|
||||
val assignedSlot: String? = null,
|
||||
val rawHexCode: String? = null,
|
||||
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
|
||||
val status: InfraredKeyStatus = InfraredKeyStatus.UNLEARNED,
|
||||
val lastErrorCode: String? = null,
|
||||
val lastErrorDetail: String? = null,
|
||||
)
|
||||
|
||||
+6
@@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface VirtualRemoteRepository {
|
||||
fun observeRemotes(
|
||||
@@ -25,6 +26,11 @@ interface VirtualRemoteRepository {
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<SlotQuotaSummary>>
|
||||
|
||||
suspend fun slotQuota(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<SlotQuotaSummary> = observeSlotQuota(profileId, deviceName).first()
|
||||
|
||||
suspend fun getRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SceneExecutionFlowTest {
|
||||
@Test
|
||||
fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest {
|
||||
val scene = Scene(
|
||||
id = SceneId("scene"),
|
||||
profileId = ProviderProfileId("profile"),
|
||||
name = "测试情景",
|
||||
)
|
||||
val executor = DefaultSceneExecutor(
|
||||
socketGateway = object : SocketGateway {
|
||||
override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) =
|
||||
DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
|
||||
override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) =
|
||||
DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
|
||||
},
|
||||
infraredGateway = object : InfraredGateway {
|
||||
override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
|
||||
override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
|
||||
override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List<InfraredCode>) = DomainResult.Success(emptyList<InfraredCodeDownloadResult>())
|
||||
},
|
||||
)
|
||||
|
||||
val updates = executor.execute(scene, SceneExecutionId("run-1")).toList()
|
||||
|
||||
assertTrue(updates.first() is SceneExecutionUpdate.Started)
|
||||
assertTrue(updates.last() is SceneExecutionUpdate.Completed)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.flagship.abox.manager.domain.activity
|
||||
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||
import com.flagship.abox.manager.domain.ActivityEventId
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivityRepository
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.domain.ActivityTarget
|
||||
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ActivityContractTest {
|
||||
@Test
|
||||
fun eventDoesNotContainCredentialsOrPayloadFields() {
|
||||
val event = ActivityEvent(
|
||||
id = ActivityEventId("event-1"),
|
||||
profileId = ProviderProfileId("profile-1"),
|
||||
occurredAtEpochMillis = 10L,
|
||||
kind = ActivityEventKind.SOCKET_COMMAND,
|
||||
source = ActivitySource.USER,
|
||||
outcome = ActivityOutcome.SUCCEEDED,
|
||||
target = ActivityTarget(ActivityTargetType.DEVICE, "device-1", "客厅插座"),
|
||||
errorCode = "20504",
|
||||
)
|
||||
|
||||
val fields = ActivityEvent::class.java.declaredFields.map { it.name }
|
||||
assertFalse(fields.any { it.contains("password", ignoreCase = true) })
|
||||
assertFalse(fields.any { it.contains("token", ignoreCase = true) })
|
||||
assertFalse(fields.any { it.contains("payload", ignoreCase = true) })
|
||||
assertTrue(event.errorCode == "20504")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorOrdersEqualTimestampsById() {
|
||||
val first = ActivityEventCursor(10L, ActivityEventId("a"))
|
||||
val second = ActivityEventCursor(10L, ActivityEventId("b"))
|
||||
|
||||
assertTrue(first < second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordingFailureDoesNotAlterOriginalDomainResult() = runTest {
|
||||
val repository = object : ActivityRepository {
|
||||
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
|
||||
MutableStateFlow(DomainResult.Success(emptyList()))
|
||||
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) =
|
||||
DomainResult.Success(emptyList<ActivityEvent>())
|
||||
override suspend fun append(event: ActivityEvent) = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.Unknown())
|
||||
}
|
||||
val original: DomainResult<String> = DomainResult.Success("command-complete")
|
||||
|
||||
val recorded = repository.append(testEvent())
|
||||
|
||||
assertTrue(recorded is DomainResult.Failure)
|
||||
assertTrue(original is DomainResult.Success)
|
||||
assertTrue((original as DomainResult.Success).value == "command-complete")
|
||||
}
|
||||
|
||||
private fun testEvent() = ActivityEvent(
|
||||
id = ActivityEventId("event-1"),
|
||||
profileId = ProviderProfileId("profile-1"),
|
||||
occurredAtEpochMillis = 10L,
|
||||
kind = ActivityEventKind.SESSION_SIGNED_IN,
|
||||
source = ActivitySource.SYSTEM,
|
||||
outcome = ActivityOutcome.SUCCEEDED,
|
||||
target = ActivityTarget(ActivityTargetType.PROFILE, "profile-1", "账户"),
|
||||
)
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.flagship.abox.manager.domain.automation
|
||||
|
||||
import com.flagship.abox.manager.domain.AutomationSnapshot
|
||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||
import com.flagship.abox.manager.domain.ConditionsEvaluation
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.Scene
|
||||
import com.flagship.abox.manager.domain.SceneCondition
|
||||
import com.flagship.abox.manager.domain.SceneConditionEvaluator
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneTrigger
|
||||
import com.flagship.abox.manager.domain.SceneTriggerEvaluator
|
||||
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||
import com.flagship.abox.manager.domain.TriggerEvaluation
|
||||
import java.time.LocalDateTime
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SceneEvaluatorTest {
|
||||
private val profile = ProviderProfileId("profile")
|
||||
private val sensor = DeviceName("sensor")
|
||||
private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30)
|
||||
private val triggerEvaluator = SceneTriggerEvaluator()
|
||||
private val conditionEvaluator = SceneConditionEvaluator()
|
||||
|
||||
@Test
|
||||
fun timeMatchesConfiguredWeekday() {
|
||||
val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3))
|
||||
val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
|
||||
assertTrue(result is TriggerEvaluation.Matched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeRangeCrossesMidnight() {
|
||||
val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00")
|
||||
val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45))
|
||||
assertTrue(result is ConditionsEvaluation.Satisfied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureComparisonAndMissingReadingFailClosed() {
|
||||
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0)
|
||||
val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
|
||||
val reading = AutomationSnapshot(
|
||||
1L,
|
||||
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))),
|
||||
)
|
||||
val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime)
|
||||
assertTrue(missing is TriggerEvaluation.Unavailable)
|
||||
assertTrue(matched is TriggerEvaluation.Matched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allConditionsMustPass() {
|
||||
val reading = AutomationSnapshot(
|
||||
1L,
|
||||
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
|
||||
)
|
||||
val conditions = listOf(
|
||||
SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0),
|
||||
SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0),
|
||||
)
|
||||
assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidNumberReturnsUnavailable() {
|
||||
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN)
|
||||
val reading = AutomationSnapshot(
|
||||
1L,
|
||||
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
|
||||
)
|
||||
assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable)
|
||||
}
|
||||
|
||||
private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger))
|
||||
}
|
||||
+42
@@ -116,4 +116,46 @@ class VirtualRemoteUseCaseTest {
|
||||
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
|
||||
assertEquals(null, fetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staticKeyAllocationUsesSlotsAcrossRemotesOnTheSameHardware() = runTest {
|
||||
val firstRemote = VirtualRemote(
|
||||
id = VirtualRemoteId("remote-one"),
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
displayName = "First",
|
||||
type = RemoteDeviceType.TV,
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
createdAtEpochMillis = 1L,
|
||||
updatedAtEpochMillis = 1L,
|
||||
)
|
||||
repository.createRemote(
|
||||
firstRemote,
|
||||
listOf(
|
||||
RemoteKeyBinding(
|
||||
id = "binding-one",
|
||||
remoteId = firstRemote.id,
|
||||
profileId = profileId,
|
||||
buttonId = "POWER",
|
||||
displayName = "Power",
|
||||
assignedSlot = "1",
|
||||
),
|
||||
),
|
||||
)
|
||||
val secondRemote = VirtualRemote(
|
||||
id = VirtualRemoteId("remote-two"),
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
displayName = "Second",
|
||||
type = RemoteDeviceType.TV,
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
createdAtEpochMillis = 2L,
|
||||
updatedAtEpochMillis = 2L,
|
||||
)
|
||||
repository.createRemote(secondRemote, emptyList())
|
||||
|
||||
val result = AddVirtualRemoteKey(repository)(profileId, secondRemote.id, "Volume")
|
||||
|
||||
assertEquals("2", (result as DomainResult.Success).value.assignedSlot)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user