Compare commits
8
Commits
main
...
c21ae4e0d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c21ae4e0d9
|
||
|
|
c9e9353a1b
|
||
|
|
ea9f62632f
|
||
|
|
c9fc1ba711
|
||
|
|
435f0d37b4
|
||
|
|
34c118394e
|
||
|
|
849be2685f
|
||
|
|
b9ed2cf010
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
|||||||
|
# 红外学习与发射控制设计
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环:
|
||||||
|
|
||||||
|
1. 在登录成功后的现有页面提供一个临时入口;
|
||||||
|
2. 手动配置并记住一个 ABSDK 红外设备名;
|
||||||
|
3. 创建并命名多个红外按键;
|
||||||
|
4. 通过 ABSDK 发起红外学习;
|
||||||
|
5. 保存可重复使用的按键绑定;
|
||||||
|
6. 通过 ABSDK 发射已学习按键;
|
||||||
|
7. 支持 Fake 和真实 ABSDK 调试模式;
|
||||||
|
8. 为后续生活场景保留稳定按键引用。
|
||||||
|
|
||||||
|
首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。
|
||||||
|
|
||||||
|
## 2. 已确认的 ABSDK 能力边界
|
||||||
|
|
||||||
|
ABSDK 0630 提供以下红外公开方法:
|
||||||
|
|
||||||
|
```java
|
||||||
|
ABRet sendIr(String irDevName, String irKey)
|
||||||
|
ABRet studyIrByIrDevName(String irDevName, String irKey)
|
||||||
|
ABRet downloadIrCodes(List<IrInfo> irInfos)
|
||||||
|
```
|
||||||
|
|
||||||
|
现有领域层已将其隔离为 `InfraredGateway`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface InfraredGateway {
|
||||||
|
suspend fun sendKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun learnKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun downloadCodes(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
codes: List<InfraredCode>,
|
||||||
|
): DomainResult<List<InfraredCodeDownloadResult>>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本设计必须遵守以下事实:
|
||||||
|
|
||||||
|
- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值;
|
||||||
|
- ABSDK 没有公开停止学习方法;
|
||||||
|
- ABSDK 没有公开获取学习结果或读取已学习原始码的方法;
|
||||||
|
- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果;
|
||||||
|
- ABSDK 没有设备扫描或设备列表接口;
|
||||||
|
- 红外方法按 `irDevName + irKey` 寻址;
|
||||||
|
- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行;
|
||||||
|
- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。
|
||||||
|
|
||||||
|
因此首版保存的是本地按键绑定,而不是原始红外波形或码值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProviderProfileId + DeviceName + sdkKey + displayName + learning status
|
||||||
|
```
|
||||||
|
|
||||||
|
真实硬件闭环成立的判据是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
learnKey(deviceName, sdkKey) 成功
|
||||||
|
↓
|
||||||
|
sendKey(deviceName, sdkKey)
|
||||||
|
↓
|
||||||
|
实体被控设备产生正确响应
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
|
||||||
|
### 3.1 本阶段包含
|
||||||
|
|
||||||
|
- 红外功能的领域模型和本地持久化端口;
|
||||||
|
- Room 数据库、DAO 和 Repository;
|
||||||
|
- 学习、重试、发射、删除和恢复应用用例;
|
||||||
|
- 红外 ViewModel 和简单 Compose 页面;
|
||||||
|
- 登录成功页面的临时红外入口;
|
||||||
|
- 手动输入并记住一个 `irDevName`;
|
||||||
|
- 单红外控制器、多按键;
|
||||||
|
- Fake/Real ABSDK 显式调试模式;
|
||||||
|
- 自动化测试;
|
||||||
|
- 真机验收清单;
|
||||||
|
- 面向未来生活场景的稳定按键引用。
|
||||||
|
|
||||||
|
### 3.2 本阶段不包含
|
||||||
|
|
||||||
|
- 最终 UI 视觉规范或复杂遥控器布局;
|
||||||
|
- 多个遥控器管理界面;
|
||||||
|
- ABSDK 不存在的设备扫描;
|
||||||
|
- 原始红外码读取或持久化;
|
||||||
|
- 红外码库导入 UI;
|
||||||
|
- 硬件停止学习;
|
||||||
|
- 完整生活场景模型、规则引擎或场景执行器;
|
||||||
|
- Release 中默认启用真实 ABSDK;
|
||||||
|
- 绕过或隐藏厂商明文 HTTP 风险;
|
||||||
|
- 删除 ABox 服务端已学习按键。
|
||||||
|
|
||||||
|
## 4. 架构
|
||||||
|
|
||||||
|
采用独立应用编排层,保持现有 `InfraredGateway` 作为纯硬件能力端口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
登录成功页面
|
||||||
|
└─ 临时入口:红外控制
|
||||||
|
↓
|
||||||
|
InfraredControlScreen
|
||||||
|
↓
|
||||||
|
InfraredControlViewModel
|
||||||
|
↓
|
||||||
|
应用用例
|
||||||
|
├─ LearnInfraredKey
|
||||||
|
├─ RetryInfraredKeyLearning
|
||||||
|
├─ SendInfraredKey
|
||||||
|
├─ DeleteInfraredKey
|
||||||
|
├─ RecoverInterruptedInfraredLearning
|
||||||
|
└─ ObserveInfraredKeys
|
||||||
|
↙ ↘
|
||||||
|
InfraredKeyRepository InfraredGateway
|
||||||
|
↓ ├─ FakeInfraredGateway
|
||||||
|
RoomInfraredKeyRepository └─ AbsdkBridge
|
||||||
|
↓
|
||||||
|
ABSDK 0630
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 边界原则
|
||||||
|
|
||||||
|
- `InfraredGateway` 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理;
|
||||||
|
- 不修改 `learnKey()` 返回类型,不虚构 ABSDK 原始码结果;
|
||||||
|
- 不把 `ABSDK`、`ABRet`、`IrInfo` 或原始 Map 暴露给上层;
|
||||||
|
- ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例;
|
||||||
|
- Room Entity 不作为领域或 UI 公共模型;
|
||||||
|
- Fake 和真实 Bridge 必须共用相同的领域接口和用例;
|
||||||
|
- 所有真实 Gateway 接口必须指向同一个进程级 `AbsdkBridge` 实例,保持全局会话和互斥锁一致。
|
||||||
|
|
||||||
|
## 5. 数据模型
|
||||||
|
|
||||||
|
### 5.1 稳定标识
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@JvmInline
|
||||||
|
value class InfraredKeyId(val value: String)
|
||||||
|
|
||||||
|
data class InfraredKeyRef(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val keyId: InfraredKeyId,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。
|
||||||
|
|
||||||
|
### 5.2 学习状态
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
enum class InfraredKeyStatus {
|
||||||
|
LEARNING,
|
||||||
|
READY,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。
|
||||||
|
|
||||||
|
### 5.3 领域模型
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class SavedInfraredKey(
|
||||||
|
val id: InfraredKeyId,
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val displayName: String,
|
||||||
|
val sdkKey: String,
|
||||||
|
val status: InfraredKeyStatus,
|
||||||
|
val lastErrorCode: String?,
|
||||||
|
val createdAtEpochMillis: Long,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
字段约束:
|
||||||
|
|
||||||
|
- `id` 在 App 内稳定,供页面、删除和未来生活场景引用;
|
||||||
|
- `profileId` 用于不同登录配置的数据隔离;
|
||||||
|
- `deviceName` 是传给 ABSDK 的真实红外设备名;
|
||||||
|
- `displayName` 是用户可见名称,可以修改;
|
||||||
|
- `sdkKey` 是传给 ABSDK 的稳定 Key,学习后不随显示名改变;
|
||||||
|
- `lastErrorCode` 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷;
|
||||||
|
- 时间字段由应用层时钟提供,测试中使用可控时钟。
|
||||||
|
|
||||||
|
### 5.4 SDK Key
|
||||||
|
|
||||||
|
App 自动生成 SDK Key,不允许用户直接编辑。格式应:
|
||||||
|
|
||||||
|
- 仅包含安全 ASCII 字符;
|
||||||
|
- 在同一 Provider 和红外设备下唯一;
|
||||||
|
- 重试学习时复用;
|
||||||
|
- 修改显示名时保持不变;
|
||||||
|
- 不直接使用中文、空格或特殊字符。
|
||||||
|
|
||||||
|
建议格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app_ir_<normalized-infrared-key-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。
|
||||||
|
|
||||||
|
## 6. 本地持久化
|
||||||
|
|
||||||
|
采用 Room。Repository 边界至少支持:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface InfraredKeyRepository {
|
||||||
|
fun observeKeys(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): Flow<DomainResult<List<SavedInfraredKey>>>
|
||||||
|
|
||||||
|
suspend fun getKey(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
): DomainResult<SavedInfraredKey?>
|
||||||
|
|
||||||
|
suspend fun insertLearningKey(
|
||||||
|
key: SavedInfraredKey,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun updateStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
status: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun renameKey(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun deleteKey(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun findInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
): DomainResult<List<SavedInfraredKey>>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。
|
||||||
|
|
||||||
|
### 6.1 数据库约束
|
||||||
|
|
||||||
|
- 主键:`InfraredKeyId`;
|
||||||
|
- 索引:`ProviderProfileId + DeviceName`;
|
||||||
|
- 唯一约束:`ProviderProfileId + DeviceName + sdkKey`;
|
||||||
|
- 查询和删除必须包含 Provider 隔离;
|
||||||
|
- 不保存原始红外码;
|
||||||
|
- 不把 Room Entity 暴露给领域层;
|
||||||
|
- 首版预留 schema version 和迁移测试基础。
|
||||||
|
|
||||||
|
### 6.2 单设备策略
|
||||||
|
|
||||||
|
页面记住一个当前 `irDevName`,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。
|
||||||
|
|
||||||
|
设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。
|
||||||
|
|
||||||
|
## 7. 学习流程
|
||||||
|
|
||||||
|
`LearnInfraredKey` 的流程:
|
||||||
|
|
||||||
|
1. 验证用户已登录且存在有效 `ProviderProfileId`;
|
||||||
|
2. 验证 `deviceName` 和 `displayName` 非空;
|
||||||
|
3. 生成稳定 `InfraredKeyId` 和 `sdkKey`;
|
||||||
|
4. 先写入状态为 `LEARNING` 的记录;
|
||||||
|
5. 在应用级协程作用域调用 `InfraredGateway.learnKey()`;
|
||||||
|
6. 成功时更新为 `READY` 并清空错误码;
|
||||||
|
7. 失败时更新为 `FAILED` 并保存脱敏错误码;
|
||||||
|
8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key;
|
||||||
|
9. 页面通过 Room Flow 观察最终状态。
|
||||||
|
|
||||||
|
### 7.1 重试
|
||||||
|
|
||||||
|
重试必须:
|
||||||
|
|
||||||
|
- 使用原 `InfraredKeyId`;
|
||||||
|
- 使用原 `sdkKey`;
|
||||||
|
- 先将状态更新为 `LEARNING`;
|
||||||
|
- 再调用 ABSDK;
|
||||||
|
- 不创建重复按键记录。
|
||||||
|
|
||||||
|
### 7.2 页面离开
|
||||||
|
|
||||||
|
学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。
|
||||||
|
|
||||||
|
这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。
|
||||||
|
|
||||||
|
### 7.3 进程恢复
|
||||||
|
|
||||||
|
App 启动或登录恢复后,查找遗留的 `LEARNING` 记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LEARNING → FAILED
|
||||||
|
错误提示:上次学习结果未确认,请重新学习
|
||||||
|
```
|
||||||
|
|
||||||
|
不得自动标记为 `READY`,也不得自动发射来试探设备状态。
|
||||||
|
|
||||||
|
### 7.4 取消语义
|
||||||
|
|
||||||
|
ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。
|
||||||
|
|
||||||
|
## 8. 发射流程
|
||||||
|
|
||||||
|
`SendInfraredKey` 的流程:
|
||||||
|
|
||||||
|
1. 使用 `InfraredKeyRef` 查询本地记录;
|
||||||
|
2. 记录不存在时返回领域错误;
|
||||||
|
3. 仅允许 `READY` 状态发射;
|
||||||
|
4. 取得记录中的 `profileId + deviceName + sdkKey`;
|
||||||
|
5. 调用 `InfraredGateway.sendKey()`;
|
||||||
|
6. 成功时向 UI 返回一次性成功事件;
|
||||||
|
7. 失败时保留按键 `READY` 状态,仅报告本次错误;
|
||||||
|
8. 不自动重试,避免重复操作电视、空调等实体设备。
|
||||||
|
|
||||||
|
未来生活场景通过稳定 `InfraredKeyRef` 调用同一发送用例或薄执行器:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface InfraredActionExecutor {
|
||||||
|
suspend fun execute(ref: InfraredKeyRef): DomainResult<Unit>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。
|
||||||
|
|
||||||
|
## 9. 删除与重命名
|
||||||
|
|
||||||
|
### 9.1 删除
|
||||||
|
|
||||||
|
- `FAILED` 和 `READY` 记录可以删除;
|
||||||
|
- `LEARNING` 中不允许删除,避免后台结果写入已不存在的记录;
|
||||||
|
- 删除只移除本地引用;
|
||||||
|
- ABSDK 没有删除服务端红外 Key 的公开接口;
|
||||||
|
- UI 必须提示“ABox 服务端可能仍保留该按键”;
|
||||||
|
- 删除后未来场景引用解析失败,不再发射。
|
||||||
|
|
||||||
|
### 9.2 重命名
|
||||||
|
|
||||||
|
- 只修改 `displayName`;
|
||||||
|
- 不修改 `id`、`deviceName` 或 `sdkKey`;
|
||||||
|
- 重命名后学习和发射能力保持不变;
|
||||||
|
- 场景引用保持有效。
|
||||||
|
|
||||||
|
## 10. Android 前端
|
||||||
|
|
||||||
|
### 10.1 临时入口
|
||||||
|
|
||||||
|
在当前登录成功后的页面添加“红外控制”按钮,导航到独立 `InfraredControlScreen`。未来正式信息架构完成后只迁移入口。
|
||||||
|
|
||||||
|
### 10.2 页面内容
|
||||||
|
|
||||||
|
首版页面只包含:
|
||||||
|
|
||||||
|
- 返回按钮和标题;
|
||||||
|
- 当前数据源标识:Fake 或真实 ABox;
|
||||||
|
- 红外控制器设备名输入和保存;
|
||||||
|
- 已学习按键列表;
|
||||||
|
- 学习新按键按钮;
|
||||||
|
- 按键状态;
|
||||||
|
- READY 按键的发射按钮;
|
||||||
|
- FAILED 按键的重试按钮;
|
||||||
|
- 删除入口和确认提示;
|
||||||
|
- 基础错误反馈。
|
||||||
|
|
||||||
|
不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。
|
||||||
|
|
||||||
|
### 10.3 设备名
|
||||||
|
|
||||||
|
- ABSDK 没有设备扫描能力;
|
||||||
|
- 首次进入时手动填写 `irDevName`;
|
||||||
|
- 保存后本地记住;
|
||||||
|
- 没有设备名时禁止学习;
|
||||||
|
- 修改设备名前提示旧按键仍绑定旧设备;
|
||||||
|
- 列表只显示当前设备名下的按键。
|
||||||
|
|
||||||
|
### 10.4 学习交互
|
||||||
|
|
||||||
|
用户输入显示名称后开始学习。页面提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请将实体遥控器对准红外控制器并按下目标按键。
|
||||||
|
```
|
||||||
|
|
||||||
|
按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。
|
||||||
|
|
||||||
|
### 10.5 发射交互
|
||||||
|
|
||||||
|
- 只有 `READY` 按键可发射;
|
||||||
|
- 发射期间禁用该按键,防止重复点击;
|
||||||
|
- 成功显示短暂提示;
|
||||||
|
- 失败显示具体错误;
|
||||||
|
- 不自动重试;
|
||||||
|
- 单次失败不删除或降级按键状态。
|
||||||
|
|
||||||
|
### 10.6 删除提示
|
||||||
|
|
||||||
|
删除确认文案应明确:
|
||||||
|
|
||||||
|
```text
|
||||||
|
仅从本机列表移除,ABox 服务端可能仍保留该按键。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Debug Fake/Real 路由
|
||||||
|
|
||||||
|
Debug 构建提供显式运行模式:
|
||||||
|
|
||||||
|
- `FAKE`:页面开发、自动化测试和错误模拟;
|
||||||
|
- `REAL_ABSDK`:复用当前登录会话,调用真实 `AbsdkBridge`。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 页面持续显示当前模式;
|
||||||
|
- 不允许在学习任务进行中切换模式;
|
||||||
|
- Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository;
|
||||||
|
- 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备;
|
||||||
|
- Fake 支持按 `ProviderProfileId + DeviceName + sdkKey` 配置成功或失败;
|
||||||
|
- 真实模式的所有 Gateway 接口绑定同一 `AbsdkBridge` 单例;
|
||||||
|
- 真实模式必须显式 opt in `CleartextAbsdkTransport`;
|
||||||
|
- Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。
|
||||||
|
|
||||||
|
真实 App 接入还必须补齐:
|
||||||
|
|
||||||
|
- `:absdk-bridge` 依赖;
|
||||||
|
- Hilt 绑定;
|
||||||
|
- `INTERNET` 权限;
|
||||||
|
- 经批准的明文网络配置;
|
||||||
|
- 登录会话与红外 Gateway 的同一 Bridge 实例;
|
||||||
|
- Token 失效后的页面返回登录流程。
|
||||||
|
|
||||||
|
## 12. 错误处理
|
||||||
|
|
||||||
|
继续使用现有 `DomainResult` 和 `DomainError`。不为首版扩充 sealed 类型,优先利用已有分类和 `externalCode`。
|
||||||
|
|
||||||
|
| 情况 | UI 反馈 | 本地状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| Token 无效 | 登录已失效,返回登录 | 学习按键为 FAILED;发射按键仍为 READY |
|
||||||
|
| 红外设备不存在 | 检查设备名 | 学习按键为 FAILED |
|
||||||
|
| 红外设备离线 | 检查供电和网络 | 学习按键为 FAILED;发射按键仍为 READY |
|
||||||
|
| KEY/码值不存在 | 建议重新学习 | 发射按键仍为 READY,由用户决定重学 |
|
||||||
|
| 红外发射失败 | 显示失败,可手动重试 | READY |
|
||||||
|
| 网络超时 | 网络或 ABox 服务不可用 | 学习按键为 FAILED;发射按键仍为 READY |
|
||||||
|
| 本地保存失败 | 无法开始学习 | 不调用硬件学习 |
|
||||||
|
| 学习成功但最终状态写入失败 | 显示数据保存异常并保留恢复信息 | 不生成新 sdkKey |
|
||||||
|
| 未知错误 | 通用提示及脱敏错误码 | 按操作类型处理 |
|
||||||
|
|
||||||
|
日志不得包含:
|
||||||
|
|
||||||
|
- 用户密码;
|
||||||
|
- Token;
|
||||||
|
- 原始 SDK Map;
|
||||||
|
- 原始红外码或载荷;
|
||||||
|
- 可用于重放的敏感字段。
|
||||||
|
|
||||||
|
## 13. 自动化测试
|
||||||
|
|
||||||
|
### 13.1 应用用例
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
|
||||||
|
1. 学习前先保存 `LEARNING`;
|
||||||
|
2. 学习成功更新为 `READY`;
|
||||||
|
3. 学习失败更新为 `FAILED`;
|
||||||
|
4. 重试复用原 `sdkKey`;
|
||||||
|
5. 修改显示名不改变 `sdkKey`;
|
||||||
|
6. 只有 `READY` 可以发射;
|
||||||
|
7. 发射使用保存的 `deviceName + sdkKey`;
|
||||||
|
8. 不同 Provider 数据隔离;
|
||||||
|
9. 遗留 `LEARNING` 恢复为 `FAILED`;
|
||||||
|
10. 删除后稳定引用失效;
|
||||||
|
11. ViewModel 销毁后应用级学习任务仍可落库;
|
||||||
|
12. 发射失败不改变 READY;
|
||||||
|
13. 进行中的学习阻止运行模式切换。
|
||||||
|
|
||||||
|
### 13.2 Room
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
|
||||||
|
- 新增、观察、查询、重命名和删除;
|
||||||
|
- `ProviderProfileId + DeviceName` 过滤;
|
||||||
|
- `sdkKey` 唯一约束;
|
||||||
|
- Provider 隔离;
|
||||||
|
- 状态转换;
|
||||||
|
- 稳定 ID 跨重命名不变;
|
||||||
|
- 首版 schema 和迁移测试基础;
|
||||||
|
- 不存储原始红外载荷。
|
||||||
|
|
||||||
|
### 13.3 Fake
|
||||||
|
|
||||||
|
扩展现有 Fake,覆盖:
|
||||||
|
|
||||||
|
- 按 Provider、设备和 Key 脚本化结果;
|
||||||
|
- 调用参数与顺序;
|
||||||
|
- 连续响应;
|
||||||
|
- 成功、离线、超时和通用失败;
|
||||||
|
- 不同 Provider 互不影响。
|
||||||
|
|
||||||
|
### 13.4 Bridge
|
||||||
|
|
||||||
|
保留现有覆盖并补充:
|
||||||
|
|
||||||
|
- 学习参数和调用顺序;
|
||||||
|
- 发射参数和调用顺序;
|
||||||
|
- 红外错误码 `20201` 至 `20206` 的映射;
|
||||||
|
- 学习、发射与其他设备调用的全局串行;
|
||||||
|
- Token 失效清理会话;
|
||||||
|
- SDK 空值和异常防御;
|
||||||
|
- 不改变 `downloadCodes()` 的部分成功语义。
|
||||||
|
|
||||||
|
### 13.5 ViewModel 与 Compose
|
||||||
|
|
||||||
|
只验证功能,不验证最终视觉:
|
||||||
|
|
||||||
|
- 无设备名不能学习;
|
||||||
|
- 保存和恢复设备名;
|
||||||
|
- 创建学习任务;
|
||||||
|
- 显示学习中、成功和失败;
|
||||||
|
- 重试学习;
|
||||||
|
- 点击发射并防重复点击;
|
||||||
|
- 删除确认;
|
||||||
|
- Fake/Real 模式标识;
|
||||||
|
- 登录失效引导;
|
||||||
|
- 基本中文和英文资源。
|
||||||
|
|
||||||
|
## 14. 真机验收
|
||||||
|
|
||||||
|
第一轮使用现有 ABox 主机和红外控制器:
|
||||||
|
|
||||||
|
1. 登录真实账号;
|
||||||
|
2. 输入后台提供的准确 `irDevName`;
|
||||||
|
3. 创建唯一按键,例如“测试电源”;
|
||||||
|
4. 调用学习并记录耗时、返回码和消息;
|
||||||
|
5. 学习成功后立即发射;
|
||||||
|
6. 验证实体电器是否响应;
|
||||||
|
7. 用同一按键重复发射;
|
||||||
|
8. 重新学习同一 `sdkKey`,验证覆盖或报错行为;
|
||||||
|
9. 红外控制器断电后验证离线错误;
|
||||||
|
10. 不按实体遥控器,验证实际超时行为;
|
||||||
|
11. 页面退出后验证后台结果落库;
|
||||||
|
12. 进程被杀后验证遗留 LEARNING 恢复;
|
||||||
|
13. Token 失效后验证返回登录;
|
||||||
|
14. 记录 SDK 是否存在未文档化 `dicDatas`,但不将内容写入生产日志。
|
||||||
|
|
||||||
|
### 14.1 必须验证的未知项
|
||||||
|
|
||||||
|
- 学习调用是立即返回还是阻塞到捕获完成;
|
||||||
|
- 学习成功是否自动绑定 `sdkKey`;
|
||||||
|
- 同一 `sdkKey` 重学是否覆盖;
|
||||||
|
- 无信号时实际超时时间;
|
||||||
|
- 页面离开后底层调用是否可完成;
|
||||||
|
- 设备名格式、大小写和特殊字符规则;
|
||||||
|
- 服务端是否永久保留已学习 Key;
|
||||||
|
- SDK 是否返回未文档化数据;
|
||||||
|
- `sdkKey` 的长度限制;
|
||||||
|
- 真实模式所需初始化信息和调用顺序。
|
||||||
|
|
||||||
|
这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。
|
||||||
|
|
||||||
|
## 15. 验收标准
|
||||||
|
|
||||||
|
本阶段完成需要满足:
|
||||||
|
|
||||||
|
- 登录成功页面可以进入简单红外页面;
|
||||||
|
- 可以手动配置并恢复一个红外设备名;
|
||||||
|
- 可以创建多个命名按键;
|
||||||
|
- 学习调用前已持久化 LEARNING;
|
||||||
|
- 学习成功或失败能正确落库;
|
||||||
|
- READY 按键可以调用发射;
|
||||||
|
- 显示名修改不影响发射;
|
||||||
|
- Fake 模式可以覆盖主要状态和错误;
|
||||||
|
- Real 模式明确标识并调用真实 Bridge;
|
||||||
|
- 页面退出后学习任务仍可更新状态;
|
||||||
|
- 进程中断后遗留学习状态可以保守恢复;
|
||||||
|
- 自动化测试通过;
|
||||||
|
- 真机至少完成一次“学习成功 → 发射 → 实体响应”;
|
||||||
|
- 未确认硬件语义被记录,不伪装为已支持;
|
||||||
|
- 未来生活场景可以通过稳定 `InfraredKeyRef` 引用按键。
|
||||||
|
|
||||||
|
## 16. 后续工作
|
||||||
|
|
||||||
|
本设计完成后再分别规划:
|
||||||
|
|
||||||
|
1. 多遥控器和房间组织;
|
||||||
|
2. 统一产品视觉和正式导航入口;
|
||||||
|
3. 红外码库导入;
|
||||||
|
4. 生活场景编排;
|
||||||
|
5. Scene 顺序执行和单项结果;
|
||||||
|
6. 厂商明文 HTTP 的正式安全方案;
|
||||||
|
7. 真机验证后对学习语义、超时和错误模型的修订。
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
fun interface InfraredKeyIdGenerator {
|
||||||
|
fun nextId(): InfraredKeyId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface EpochMillisClock {
|
||||||
|
fun now(): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
class InfraredKeyFactory(
|
||||||
|
private val idGenerator: InfraredKeyIdGenerator,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
fun create(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
displayName: String,
|
||||||
|
): DomainResult<SavedInfraredKey> {
|
||||||
|
val normalizedName = displayName.trim()
|
||||||
|
if (
|
||||||
|
profileId.value.isBlank() ||
|
||||||
|
profileId.value != profileId.value.trim() ||
|
||||||
|
deviceName.value.isBlank() ||
|
||||||
|
deviceName.value != deviceName.value.trim() ||
|
||||||
|
normalizedName.isBlank()
|
||||||
|
) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val id = idGenerator.nextId()
|
||||||
|
val canonicalId = canonicalizeId(id.value)
|
||||||
|
?: return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
||||||
|
)
|
||||||
|
val now = clock.now()
|
||||||
|
return DomainResult.Success(
|
||||||
|
SavedInfraredKey(
|
||||||
|
id = id,
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
displayName = normalizedName,
|
||||||
|
sdkKey = "app_ir_$canonicalId",
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
createdAtEpochMillis = now,
|
||||||
|
updatedAtEpochMillis = now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canonicalizeId(value: String): String? = when {
|
||||||
|
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
|
||||||
|
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
|
||||||
|
val STANDARD_UUID = Regex(
|
||||||
|
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class InfraredKeyId(val value: String)
|
||||||
|
|
||||||
|
data class InfraredKeyRef(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val keyId: InfraredKeyId,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class InfraredKeyStatus {
|
||||||
|
LEARNING,
|
||||||
|
READY,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
const val INTERRUPTED_LEARNING_ERROR_DETAIL =
|
||||||
|
"Interrupted learning result is unknown; retry required"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned,
|
||||||
|
* sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there.
|
||||||
|
*/
|
||||||
|
data class SavedInfraredKey(
|
||||||
|
val id: InfraredKeyId,
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val displayName: String,
|
||||||
|
val sdkKey: String,
|
||||||
|
val status: InfraredKeyStatus,
|
||||||
|
val lastErrorCode: String?,
|
||||||
|
val lastErrorDetail: String?,
|
||||||
|
val createdAtEpochMillis: Long,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
interface InfraredKeyRepository {
|
||||||
|
fun observeKeys(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): Flow<DomainResult<List<SavedInfraredKey>>>
|
||||||
|
|
||||||
|
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
|
||||||
|
|
||||||
|
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun transitionStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
lastErrorDetail: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun rename(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor
|
||||||
|
* error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
|
||||||
|
*/
|
||||||
|
suspend fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Int>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfraredDeviceConfigurationRepository {
|
||||||
|
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
|
||||||
|
|
||||||
|
suspend fun setDeviceName(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class LearnInfraredKey(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
private val factory: InfraredKeyFactory,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
displayName: String,
|
||||||
|
): DomainResult<InfraredKeyId> {
|
||||||
|
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
|
||||||
|
is DomainResult.Success -> result.value
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val result = repository.insert(key)) {
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (
|
||||||
|
val result = gateway.learnKey(
|
||||||
|
profileId = key.profileId,
|
||||||
|
deviceName = key.deviceName,
|
||||||
|
key = key.sdkKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> when (
|
||||||
|
val transition = repository.transitionStatus(
|
||||||
|
ref = key.ref(),
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.READY,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> DomainResult.Success(key.id)
|
||||||
|
is DomainResult.Failure -> transition
|
||||||
|
}
|
||||||
|
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = key.ref(),
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = result.error.externalCode,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetryInfraredKeyLearning(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||||
|
val key = when (val result = repository.getKey(ref)) {
|
||||||
|
is DomainResult.Success -> result.value
|
||||||
|
?: return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key not found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
if (key.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (
|
||||||
|
val result = repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(
|
||||||
|
InfraredKeyStatus.FAILED,
|
||||||
|
InfraredKeyStatus.READY,
|
||||||
|
),
|
||||||
|
targetStatus = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (
|
||||||
|
val result = gateway.learnKey(
|
||||||
|
profileId = key.profileId,
|
||||||
|
deviceName = key.deviceName,
|
||||||
|
key = key.sdkKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.READY,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = result.error.externalCode,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
|
profileId = profileId,
|
||||||
|
keyId = id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class SendInfraredKey(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||||
|
val key = when (val result = repository.getKey(ref)) {
|
||||||
|
is DomainResult.Success -> result.value
|
||||||
|
?: return DomainResult.Failure(
|
||||||
|
DomainError.NoData(detail = "Infrared key not found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
if (key.status != InfraredKeyStatus.READY) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return gateway.sendKey(
|
||||||
|
profileId = key.profileId,
|
||||||
|
deviceName = key.deviceName,
|
||||||
|
key = key.sdkKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenameInfraredKey(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val trimmed = displayName.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return repository.rename(ref, trimmed, clock.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteInfraredKey(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
|
||||||
|
repository.deleteIfNotLearning(ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecoverInterruptedInfraredLearning(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
|
||||||
|
repository.failInterruptedLearning(profileId, clock.now())
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCode
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
data class InfraredLearningCall(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val key: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InfraredStatusTransitionCall(
|
||||||
|
val ref: InfraredKeyRef,
|
||||||
|
val expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
val targetStatus: InfraredKeyStatus,
|
||||||
|
val lastErrorCode: String?,
|
||||||
|
val lastErrorDetail: String?,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InfraredSendCall(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val key: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InfraredRenameCall(
|
||||||
|
val ref: InfraredKeyRef,
|
||||||
|
val displayName: String,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InfraredRecoveryCall(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||||
|
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
||||||
|
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
||||||
|
|
||||||
|
var getError: DomainError? = null
|
||||||
|
var insertError: DomainError? = null
|
||||||
|
var renameError: DomainError? = null
|
||||||
|
var deleteError: DomainError? = null
|
||||||
|
var recoveryError: DomainError? = null
|
||||||
|
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
|
||||||
|
|
||||||
|
var onInsert: ((SavedInfraredKey) -> Unit)? = null
|
||||||
|
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
|
||||||
|
|
||||||
|
val insertedKeys = mutableListOf<SavedInfraredKey>()
|
||||||
|
val getCalls = mutableListOf<InfraredKeyRef>()
|
||||||
|
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
|
||||||
|
val renameCalls = mutableListOf<InfraredRenameCall>()
|
||||||
|
val deleteCalls = mutableListOf<InfraredKeyRef>()
|
||||||
|
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
|
||||||
|
|
||||||
|
override fun observeKeys(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
|
||||||
|
DomainResult.Success(
|
||||||
|
values.filter { it.profileId == profileId && it.deviceName == deviceName },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
||||||
|
getCalls += ref
|
||||||
|
getError?.let { return DomainResult.Failure(it) }
|
||||||
|
return DomainResult.Success(keys[ref])
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
|
||||||
|
insertedKeys += key
|
||||||
|
onInsert?.invoke(key)
|
||||||
|
insertError?.let { return DomainResult.Failure(it) }
|
||||||
|
keys[key.ref()] = key
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transitionStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
lastErrorDetail: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val call = InfraredStatusTransitionCall(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = expectedStatuses,
|
||||||
|
targetStatus = targetStatus,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
lastErrorDetail = lastErrorDetail,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
transitionCalls += call
|
||||||
|
onTransition?.invoke(call)
|
||||||
|
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
|
||||||
|
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
if (existing.status !in expectedStatuses) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key status changed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
status = targetStatus,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
lastErrorDetail = lastErrorDetail,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
|
||||||
|
renameError?.let { return DomainResult.Failure(it) }
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
displayName = displayName,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||||
|
deleteCalls += ref
|
||||||
|
deleteError?.let { return DomainResult.Failure(it) }
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
if (existing.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keys.remove(ref)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Int> {
|
||||||
|
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
|
||||||
|
recoveryError?.let { return DomainResult.Failure(it) }
|
||||||
|
var changed = 0
|
||||||
|
keys.replaceAll { _, key ->
|
||||||
|
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
changed += 1
|
||||||
|
key.copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seed(key: SavedInfraredKey) {
|
||||||
|
keys[key.ref()] = key
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onlyKey(): SavedInfraredKey = keys.values.single()
|
||||||
|
|
||||||
|
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
state.value = keys.values.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingInfraredGateway : InfraredGateway {
|
||||||
|
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
|
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
|
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
||||||
|
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
||||||
|
val sentKeys = mutableListOf<InfraredSendCall>()
|
||||||
|
|
||||||
|
override suspend fun learnKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
|
||||||
|
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
sentKeys += InfraredSendCall(profileId, deviceName, key)
|
||||||
|
return sendResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadCodes(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
codes: List<InfraredCode>,
|
||||||
|
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
|
profileId = profileId,
|
||||||
|
keyId = id,
|
||||||
|
)
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class InfraredKeyFactoryTest {
|
||||||
|
private val id = InfraredKeyId("01J5MOLERABC123")
|
||||||
|
private val factory = InfraredKeyFactory(
|
||||||
|
idGenerator = InfraredKeyIdGenerator { id },
|
||||||
|
clock = EpochMillisClock { 1_724_435_200_000L },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createsLearningKeyWithStableAsciiSdkKey() {
|
||||||
|
val result = factory.create(
|
||||||
|
ProviderProfileId("abox"),
|
||||||
|
DeviceName("ir-1"),
|
||||||
|
"空调开关",
|
||||||
|
) as DomainResult.Success
|
||||||
|
|
||||||
|
assertEquals(id, result.value.id)
|
||||||
|
assertEquals("app_ir_01j5molerabc123", result.value.sdkKey)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, result.value.status)
|
||||||
|
assertEquals(null, result.value.lastErrorCode)
|
||||||
|
assertEquals(null, result.value.lastErrorDetail)
|
||||||
|
assertEquals("空调开关", result.value.displayName)
|
||||||
|
assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis)
|
||||||
|
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBlankDeviceOrDisplayName() {
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsIdentifiersWithSurroundingWhitespace() {
|
||||||
|
assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure)
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsLossyOrOversizedIds() {
|
||||||
|
assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure)
|
||||||
|
assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canonicalizesStandardUuidId() {
|
||||||
|
val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success
|
||||||
|
|
||||||
|
assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun factoryWithId(value: String) = InfraredKeyFactory(
|
||||||
|
idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) },
|
||||||
|
clock = EpochMillisClock { 1_724_435_200_000L },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun InfraredKeyFactory.createValidKey() = create(
|
||||||
|
ProviderProfileId("abox"),
|
||||||
|
DeviceName("ir-1"),
|
||||||
|
"Power",
|
||||||
|
)
|
||||||
|
}
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LearnInfraredKeyTest {
|
||||||
|
private val profile = ProviderProfileId("abox")
|
||||||
|
private val device = DeviceName("ir-1")
|
||||||
|
private val keyId = InfraredKeyId("key1")
|
||||||
|
private val clock = SteppingClock(100L)
|
||||||
|
private val factory = InfraredKeyFactory(
|
||||||
|
idGenerator = InfraredKeyIdGenerator { keyId },
|
||||||
|
clock = clock,
|
||||||
|
)
|
||||||
|
private val repository = InMemoryInfraredKeyRepository()
|
||||||
|
private val gateway = RecordingInfraredGateway()
|
||||||
|
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
|
||||||
|
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
|
||||||
|
val events = mutableListOf<String>()
|
||||||
|
repository.onInsert = { events += "insert:${it.status}" }
|
||||||
|
gateway.onLearn = { _, _, _ ->
|
||||||
|
events += "hardware"
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
repository.onTransition = { events += "transition:${it.targetStatus}" }
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(keyId), result)
|
||||||
|
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorCode)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertFailureNeverCallsHardware() = runTest {
|
||||||
|
val insertError = DomainError.Unknown(detail = "database unavailable")
|
||||||
|
repository.insertError = insertError
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(insertError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
|
||||||
|
val result = learn(profile, device, " ")
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(repository.insertedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
||||||
|
DomainError.Unknown(detail = "write conflict")
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
|
||||||
|
assertEquals(null, repository.transitionCalls.single().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
|
||||||
|
learn(profile, device, "Power")
|
||||||
|
|
||||||
|
val inserted = repository.insertedKeys.single()
|
||||||
|
val transition = repository.transitionCalls.single()
|
||||||
|
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
|
||||||
|
assertEquals(inserted.ref(), transition.ref)
|
||||||
|
assertEquals(100L, inserted.createdAtEpochMillis)
|
||||||
|
assertEquals(100L, inserted.updatedAtEpochMillis)
|
||||||
|
assertEquals(101L, transition.updatedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReusesStableIdAndSdkKey() = runTest {
|
||||||
|
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
|
||||||
|
repository.seed(original)
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), result)
|
||||||
|
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
|
||||||
|
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertEquals(0, repository.insertedKeys.size)
|
||||||
|
assertEquals(2, repository.transitionCalls.size)
|
||||||
|
assertEquals(
|
||||||
|
setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY),
|
||||||
|
repository.transitionCalls.first().expectedStatuses,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
repository.transitionCalls.last().expectedStatuses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryRejectsAlreadyLearningKey() = runTest {
|
||||||
|
repository.seed(learningKey())
|
||||||
|
|
||||||
|
val result = retry(repository.onlyKey().ref())
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryRejectsMissingKey() = runTest {
|
||||||
|
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
|
||||||
|
val getError = DomainError.Unknown(detail = "database unavailable")
|
||||||
|
repository.getError = getError
|
||||||
|
|
||||||
|
val result = retry(InfraredKeyRef(profile, keyId))
|
||||||
|
|
||||||
|
assertEquals(getError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
repository.onTransition = { call ->
|
||||||
|
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
|
||||||
|
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
gateway.onLearn = { _, _, _ ->
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorCode)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
||||||
|
assertEquals(100L, repository.onlyKey().updatedAtEpochMillis)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorCode)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
||||||
|
assertEquals(101L, repository.onlyKey().updatedAtEpochMillis)
|
||||||
|
assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
||||||
|
DomainError.Unknown(detail = "write conflict")
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
|
||||||
|
assertEquals(null, repository.transitionCalls.last().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failedKey(
|
||||||
|
id: InfraredKeyId = keyId,
|
||||||
|
sdkKey: String = "app_ir_key1",
|
||||||
|
) = SavedInfraredKey(
|
||||||
|
id = id,
|
||||||
|
profileId = profile,
|
||||||
|
deviceName = device,
|
||||||
|
displayName = "Power",
|
||||||
|
sdkKey = sdkKey,
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "old-error",
|
||||||
|
lastErrorDetail = "old-detail",
|
||||||
|
createdAtEpochMillis = 1L,
|
||||||
|
updatedAtEpochMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun learningKey() = failedKey().copy(
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SteppingClock(start: Long) : EpochMillisClock {
|
||||||
|
private var next = start
|
||||||
|
|
||||||
|
override fun now(): Long = next++
|
||||||
|
}
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ManageInfraredKeyTest {
|
||||||
|
private val profile = ProviderProfileId("abox")
|
||||||
|
private val otherProfile = ProviderProfileId("other")
|
||||||
|
private val repository = InMemoryInfraredKeyRepository()
|
||||||
|
private val gateway = RecordingInfraredGateway()
|
||||||
|
private val clock = SteppingManagementClock(500L)
|
||||||
|
private val send = SendInfraredKey(repository, gateway)
|
||||||
|
private val rename = RenameInfraredKey(repository, clock)
|
||||||
|
private val delete = DeleteInfraredKey(repository)
|
||||||
|
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
|
||||||
|
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
|
||||||
|
repository.seed(key)
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), send(key.ref()))
|
||||||
|
assertEquals(
|
||||||
|
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
|
||||||
|
gateway.sentKeys.single(),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(key.ref()), repository.getCalls)
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
assertTrue(repository.renameCalls.isEmpty())
|
||||||
|
assertTrue(repository.deleteCalls.isEmpty())
|
||||||
|
assertTrue(repository.recoveryCalls.isEmpty())
|
||||||
|
assertEquals(key, repository.onlyKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendFailureDoesNotDemoteReadyKey() = runTest {
|
||||||
|
val key = readyKey()
|
||||||
|
repository.seed(key)
|
||||||
|
val gatewayError = DomainError.OperationFailed("20205")
|
||||||
|
gateway.sendResult = DomainResult.Failure(gatewayError)
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
|
||||||
|
assertEquals(
|
||||||
|
listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")),
|
||||||
|
gateway.sentKeys,
|
||||||
|
)
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
|
||||||
|
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
|
||||||
|
assertEquals(
|
||||||
|
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
|
||||||
|
repository.getCalls,
|
||||||
|
)
|
||||||
|
assertTrue(gateway.sentKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
|
||||||
|
repository.seed(failedKey())
|
||||||
|
|
||||||
|
val result = send(repository.onlyKey().ref())
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.sentKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
||||||
|
val repositoryError = DomainError.Unknown(detail = "database unavailable")
|
||||||
|
repository.getError = repositoryError
|
||||||
|
|
||||||
|
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
||||||
|
|
||||||
|
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.sentKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest {
|
||||||
|
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "20206",
|
||||||
|
lastErrorDetail = "fixed-detail",
|
||||||
|
)
|
||||||
|
repository.seed(before)
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
|
||||||
|
|
||||||
|
val after = repository.onlyKey()
|
||||||
|
assertEquals("空调开关", after.displayName)
|
||||||
|
assertEquals(before.sdkKey, after.sdkKey)
|
||||||
|
assertEquals(before.id, after.id)
|
||||||
|
assertEquals(before.lastErrorCode, after.lastErrorCode)
|
||||||
|
assertEquals(before.lastErrorDetail, after.lastErrorDetail)
|
||||||
|
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
|
||||||
|
assertEquals(500L, after.updatedAtEpochMillis)
|
||||||
|
assertEquals(
|
||||||
|
InfraredRenameCall(before.ref(), "空调开关", 500L),
|
||||||
|
repository.renameCalls.single(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
|
||||||
|
val key = readyKey()
|
||||||
|
repository.seed(key)
|
||||||
|
|
||||||
|
val result = rename(key.ref(), " \t ")
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(repository.renameCalls.isEmpty())
|
||||||
|
assertEquals(key, repository.onlyKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renameReturnsRepositoryFailure() = runTest {
|
||||||
|
val key = readyKey()
|
||||||
|
repository.seed(key)
|
||||||
|
val repositoryError = DomainError.Unknown(detail = "write failed")
|
||||||
|
repository.renameError = repositoryError
|
||||||
|
|
||||||
|
val result = rename(key.ref(), "New name")
|
||||||
|
|
||||||
|
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals("Power", repository.onlyKey().displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
|
||||||
|
val key = learningKey()
|
||||||
|
repository.seed(key)
|
||||||
|
|
||||||
|
val result = delete(key.ref())
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
||||||
|
assertTrue(repository.getCalls.isEmpty())
|
||||||
|
assertTrue(repository.allKeys().isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesReadyKey() = runTest {
|
||||||
|
val key = readyKey()
|
||||||
|
repository.seed(key)
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
|
||||||
|
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
||||||
|
assertTrue(repository.getCalls.isEmpty())
|
||||||
|
assertTrue(repository.allKeys().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
|
||||||
|
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
|
||||||
|
val learningTwo = learningKey(id = InfraredKeyId("two"))
|
||||||
|
val ready = readyKey(id = InfraredKeyId("ready"))
|
||||||
|
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
|
||||||
|
repository.seed(learningOne)
|
||||||
|
repository.seed(learningTwo)
|
||||||
|
repository.seed(ready)
|
||||||
|
repository.seed(other)
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(2), recover(profile))
|
||||||
|
assertEquals(
|
||||||
|
listOf(InfraredRecoveryCall(profile, 500L)),
|
||||||
|
repository.recoveryCalls,
|
||||||
|
)
|
||||||
|
assertTrue(repository.getCalls.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
|
||||||
|
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
|
||||||
|
assertTrue(recovered.all { it.lastErrorCode == null })
|
||||||
|
assertTrue(
|
||||||
|
recovered.all {
|
||||||
|
it.lastErrorDetail == "Interrupted learning result is unknown; retry required"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(gateway.sentKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
||||||
|
val repositoryError = DomainError.Unknown(detail = "write failed")
|
||||||
|
repository.recoveryError = repositoryError
|
||||||
|
|
||||||
|
val result = recover(profile)
|
||||||
|
|
||||||
|
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(gateway.sentKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readyKey(
|
||||||
|
id: InfraredKeyId = InfraredKeyId("key1"),
|
||||||
|
profileId: ProviderProfileId = profile,
|
||||||
|
deviceName: DeviceName = DeviceName("ir-1"),
|
||||||
|
displayName: String = "Power",
|
||||||
|
sdkKey: String = "app_ir_key1",
|
||||||
|
) = SavedInfraredKey(
|
||||||
|
id = id,
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
displayName = displayName,
|
||||||
|
sdkKey = sdkKey,
|
||||||
|
status = InfraredKeyStatus.READY,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
createdAtEpochMillis = 1L,
|
||||||
|
updatedAtEpochMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failedKey() = readyKey().copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "old-error",
|
||||||
|
lastErrorDetail = "old-detail",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun learningKey(
|
||||||
|
id: InfraredKeyId = InfraredKeyId("key1"),
|
||||||
|
profileId: ProviderProfileId = profile,
|
||||||
|
lastErrorCode: String? = null,
|
||||||
|
lastErrorDetail: String? = null,
|
||||||
|
) = readyKey(id = id, profileId = profileId).copy(
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
lastErrorDetail = lastErrorDetail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SteppingManagementClock(start: Long) : EpochMillisClock {
|
||||||
|
private var next = start
|
||||||
|
|
||||||
|
override fun now(): Long = next++
|
||||||
|
}
|
||||||
+28
-2
@@ -82,6 +82,8 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||||
val downloadCalls: MutableList<InfraredDownloadCall> =
|
val downloadCalls: MutableList<InfraredDownloadCall> =
|
||||||
Collections.synchronizedList(mutableListOf())
|
Collections.synchronizedList(mutableListOf())
|
||||||
|
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||||
|
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
@@ -98,7 +100,7 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
key: String,
|
key: String,
|
||||||
): DomainResult<Unit> {
|
): DomainResult<Unit> {
|
||||||
sentKeys += InfraredCall(profileId, deviceName, key)
|
sentKeys += InfraredCall(profileId, deviceName, key)
|
||||||
return sendResult
|
return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun learnKey(
|
override suspend fun learnKey(
|
||||||
@@ -107,7 +109,25 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
key: String,
|
key: String,
|
||||||
): DomainResult<Unit> {
|
): DomainResult<Unit> {
|
||||||
learnedKeys += InfraredCall(profileId, deviceName, key)
|
learnedKeys += InfraredCall(profileId, deviceName, key)
|
||||||
return learnResult
|
return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSendResult(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
result: DomainResult<Unit>,
|
||||||
|
) {
|
||||||
|
sendResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLearnResult(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
result: DomainResult<Unit>,
|
||||||
|
) {
|
||||||
|
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun downloadCodes(
|
override suspend fun downloadCodes(
|
||||||
@@ -131,6 +151,12 @@ data class InfraredDownloadCall(
|
|||||||
val codes: List<InfraredCode>,
|
val codes: List<InfraredCode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class InfraredOperationKey(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val key: String,
|
||||||
|
)
|
||||||
|
|
||||||
private data class DeviceKey(
|
private data class DeviceKey(
|
||||||
val profileId: ProviderProfileId,
|
val profileId: ProviderProfileId,
|
||||||
val deviceName: DeviceName,
|
val deviceName: DeviceName,
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package com.flagship.abox.manager.testing.fakes
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class FakeInfraredKeyRepository : InfraredKeyRepository {
|
||||||
|
private val lock = Any()
|
||||||
|
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
||||||
|
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
||||||
|
|
||||||
|
override fun observeKeys(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
|
||||||
|
DomainResult.Success(
|
||||||
|
values.filter { it.profileId == profileId && it.deviceName == deviceName },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> =
|
||||||
|
synchronized(lock) {
|
||||||
|
DomainResult.Success(keys[ref])
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> = synchronized(lock) {
|
||||||
|
val ref = key.ref()
|
||||||
|
if (keys.keys.any { it.keyId == key.id }) {
|
||||||
|
return@synchronized invalidRequest("Infrared key id already exists")
|
||||||
|
}
|
||||||
|
if (keys.values.any {
|
||||||
|
it.profileId == key.profileId &&
|
||||||
|
it.deviceName == key.deviceName &&
|
||||||
|
it.sdkKey == key.sdkKey
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return@synchronized invalidRequest("Infrared key binding already exists")
|
||||||
|
}
|
||||||
|
keys[ref] = key
|
||||||
|
publishLocked()
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transitionStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
lastErrorDetail: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> = synchronized(lock) {
|
||||||
|
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||||
|
if (existing.status !in expectedStatuses) {
|
||||||
|
return@synchronized invalidRequest("Infrared key status changed")
|
||||||
|
}
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
status = targetStatus,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
lastErrorDetail = lastErrorDetail,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publishLocked()
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> = synchronized(lock) {
|
||||||
|
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
displayName = displayName,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publishLocked()
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> =
|
||||||
|
synchronized(lock) {
|
||||||
|
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||||
|
if (existing.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
return@synchronized invalidRequest("Cannot delete a learning infrared key")
|
||||||
|
}
|
||||||
|
keys.remove(ref)
|
||||||
|
publishLocked()
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Int> = synchronized(lock) {
|
||||||
|
var changed = 0
|
||||||
|
keys.replaceAll { _, key ->
|
||||||
|
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
changed += 1
|
||||||
|
key.copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed > 0) publishLocked()
|
||||||
|
DomainResult.Success(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishLocked() {
|
||||||
|
state.value = keys.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyNotFound(): DomainResult.Failure =
|
||||||
|
invalidRequest("Infrared key not found")
|
||||||
|
|
||||||
|
private fun invalidRequest(detail: String): DomainResult.Failure =
|
||||||
|
DomainResult.Failure(DomainError.InvalidRequest(detail = detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
||||||
|
private val lock = Any()
|
||||||
|
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
||||||
|
|
||||||
|
override fun observeDeviceName(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
||||||
|
DomainResult.Success(values[profileId])
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setDeviceName(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): DomainResult<Unit> = synchronized(lock) {
|
||||||
|
val value = deviceName.value
|
||||||
|
if (value.isBlank() || value != value.trim()) {
|
||||||
|
return@synchronized DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deviceNames.value = deviceNames.value + (profileId to deviceName)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
|
profileId = profileId,
|
||||||
|
keyId = id,
|
||||||
|
)
|
||||||
+52
@@ -54,4 +54,56 @@ class FakeDeviceGatewaysTest {
|
|||||||
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
|
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
|
||||||
assertTrue(download is DomainResult.Success)
|
assertTrue(download is DomainResult.Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||||
|
val gateway = FakeInfraredGateway()
|
||||||
|
val infraredDevice = DeviceName("ir-1")
|
||||||
|
gateway.setLearnResult(
|
||||||
|
profileOne,
|
||||||
|
infraredDevice,
|
||||||
|
"power",
|
||||||
|
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.learnKey(profileTwo, infraredDevice, "power"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.learnKey(profileOne, infraredDevice, "volume-up"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.learnKey(profileOne, DeviceName("ir-2"), "power"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||||
|
val gateway = FakeInfraredGateway()
|
||||||
|
val infraredDevice = DeviceName("ir-1")
|
||||||
|
gateway.setSendResult(
|
||||||
|
profileOne,
|
||||||
|
infraredDevice,
|
||||||
|
"power",
|
||||||
|
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.sendKey(profileTwo, infraredDevice, "power"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.sendKey(profileOne, infraredDevice, "volume-up"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
gateway.sendKey(profileOne, DeviceName("ir-2"), "power"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
package com.flagship.abox.manager.testing.fakes
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FakeInfraredKeyRepositoryTest {
|
||||||
|
private val profileOne = ProviderProfileId("one")
|
||||||
|
private val profileTwo = ProviderProfileId("two")
|
||||||
|
private val deviceOne = DeviceName("ir-1")
|
||||||
|
private val deviceTwo = DeviceName("ir-2")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
repository.observeKeys(profileOne, deviceOne).take(2).toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.insert(savedKey(id = "matching"))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
DomainResult.Success(emptyList<SavedInfraredKey>()),
|
||||||
|
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
||||||
|
),
|
||||||
|
emissions.await(),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.insert(savedKey(id = "other-profile", profileId = profileTwo))
|
||||||
|
repository.insert(savedKey(id = "other-device", deviceName = deviceTwo))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
||||||
|
repository.observeKeys(profileOne, deviceOne).first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
repository.insert(savedKey(id = "power"))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(savedKey(id = "power")),
|
||||||
|
repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(null),
|
||||||
|
repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power")))
|
||||||
|
|
||||||
|
val duplicateId = repository.insert(
|
||||||
|
savedKey(
|
||||||
|
id = "power",
|
||||||
|
profileId = profileTwo,
|
||||||
|
deviceName = deviceTwo,
|
||||||
|
sdkKey = "other-sdk-key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val duplicateBinding = repository.insert(
|
||||||
|
savedKey(id = "volume", displayName = "Volume"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertInvalidRequest(duplicateId)
|
||||||
|
assertInvalidRequest(duplicateBinding)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(listOf(savedKey(id = "power"))),
|
||||||
|
repository.observeKeys(profileOne, deviceOne).first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
||||||
|
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING))
|
||||||
|
|
||||||
|
val rejected = repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.READY),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "20206",
|
||||||
|
lastErrorDetail = "offline",
|
||||||
|
updatedAtEpochMillis = 20L,
|
||||||
|
)
|
||||||
|
assertInvalidRequest(rejected)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status)
|
||||||
|
assertNull(repository.successfulKey(ref)?.lastErrorCode)
|
||||||
|
assertNull(repository.successfulKey(ref)?.lastErrorDetail)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "20206",
|
||||||
|
lastErrorDetail = "offline",
|
||||||
|
updatedAtEpochMillis = 20L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
savedKey(
|
||||||
|
id = "power",
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "20206",
|
||||||
|
lastErrorDetail = "offline",
|
||||||
|
updatedAtEpochMillis = 20L,
|
||||||
|
),
|
||||||
|
repository.successfulKey(ref),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renameUpdatesDisplayNameAndTimestamp() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
||||||
|
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L),
|
||||||
|
)
|
||||||
|
assertEquals("Main Power", repository.successfulKey(ref)?.displayName)
|
||||||
|
assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning"))
|
||||||
|
val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready"))
|
||||||
|
repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING))
|
||||||
|
repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY))
|
||||||
|
|
||||||
|
assertInvalidRequest(repository.deleteIfNotLearning(learningRef))
|
||||||
|
assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef))
|
||||||
|
assertTrue(repository.successfulKey(learningRef) != null)
|
||||||
|
assertNull(repository.successfulKey(readyRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest {
|
||||||
|
val repository = FakeInfraredKeyRepository()
|
||||||
|
repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning"))
|
||||||
|
repository.insert(
|
||||||
|
savedKey(
|
||||||
|
id = "one-ready",
|
||||||
|
sdkKey = "one-ready",
|
||||||
|
status = InfraredKeyStatus.READY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.insert(
|
||||||
|
savedKey(
|
||||||
|
id = "two-learning",
|
||||||
|
profileId = profileTwo,
|
||||||
|
sdkKey = "two-learning",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(1),
|
||||||
|
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L),
|
||||||
|
)
|
||||||
|
|
||||||
|
val recovered = repository.successfulKey(
|
||||||
|
InfraredKeyRef(profileOne, InfraredKeyId("one-learning")),
|
||||||
|
)
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, recovered?.status)
|
||||||
|
assertNull(recovered?.lastErrorCode)
|
||||||
|
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail)
|
||||||
|
assertEquals(40L, recovered?.updatedAtEpochMillis)
|
||||||
|
assertEquals(
|
||||||
|
InfraredKeyStatus.READY,
|
||||||
|
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
InfraredKeyStatus.LEARNING,
|
||||||
|
repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(0),
|
||||||
|
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deviceConfigurationIsIsolatedByProfile() = runTest {
|
||||||
|
val repository = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(Unit),
|
||||||
|
repository.setDeviceName(profileOne, deviceOne),
|
||||||
|
)
|
||||||
|
assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first())
|
||||||
|
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest {
|
||||||
|
val repository = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
|
||||||
|
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ")))
|
||||||
|
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1")))
|
||||||
|
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 ")))
|
||||||
|
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savedKey(
|
||||||
|
id: String,
|
||||||
|
profileId: ProviderProfileId = profileOne,
|
||||||
|
deviceName: DeviceName = deviceOne,
|
||||||
|
displayName: String = "Power",
|
||||||
|
sdkKey: String = "power",
|
||||||
|
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode: String? = null,
|
||||||
|
lastErrorDetail: String? = null,
|
||||||
|
updatedAtEpochMillis: Long = 10L,
|
||||||
|
) = SavedInfraredKey(
|
||||||
|
id = InfraredKeyId(id),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
displayName = displayName,
|
||||||
|
sdkKey = sdkKey,
|
||||||
|
status = status,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
lastErrorDetail = lastErrorDetail,
|
||||||
|
createdAtEpochMillis = 10L,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun FakeInfraredKeyRepository.successfulKey(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
): SavedInfraredKey? = (getKey(ref) as DomainResult.Success<SavedInfraredKey?>).value
|
||||||
|
|
||||||
|
private fun assertInvalidRequest(result: DomainResult<*>) {
|
||||||
|
assertTrue(result is DomainResult.Failure)
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user