diff --git "a/00_\351\241\271\347\233\256\346\211\247\350\241\214\346\200\273\350\247\210.md" "b/00_\351\241\271\347\233\256\346\211\247\350\241\214\346\200\273\350\247\210.md" index 5cae2d3..e4bbe25 100644 --- "a/00_\351\241\271\347\233\256\346\211\247\350\241\214\346\200\273\350\247\210.md" +++ "b/00_\351\241\271\347\233\256\346\211\247\350\241\214\346\200\273\350\247\210.md" @@ -8,7 +8,7 @@ 当前整体状态:`IN_PROGRESS` -当前唯一阶段:`03 FakeTicket 场次票档与订单边界(READY)` +当前唯一阶段:`05 Agent 任务配置目标模式与权限(READY)` ## 目标 @@ -53,9 +53,9 @@ | 00R | [规范迁移与现状审计](docs/stages/v2/00R_规范迁移与现状审计.md) | `VERIFIED` | 2026-08-12 完成 v2 规范迁移与静态一致性检查 | | 01 | [工程与历史实现基线复验](docs/stages/v2/01_工程与历史实现基线复验.md) | `VERIFIED` | 2026-08-13:双模块测试、Lint、APK、签名门禁及 vivo Android 16 启动/旋转验证通过 | | 02 | [FakeTicket 真实布局页面骨架](docs/stages/v2/02_FakeTicket真实布局页面骨架.md) | `VERIFIED` | 2026-08-13:四页 canonical 流程、返回、旋转、稳定 ID 与 vivo Android 16 真机证据通过 | -| 03 | [FakeTicket 场次票档与订单边界](docs/stages/v2/03_FakeTicket场次票档与订单边界.md) | `READY` | 当前唯一可开始阶段;精确场次、票档顺序、数量、总额、支付页停止 | -| 04 | [FakeTicket 开售排队与异常场景](docs/stages/v2/04_FakeTicket开售排队与异常场景.md) | `REVALIDATION_REQUIRED` | 排队成功/失败/超时及风险停止 fixture | -| 05 | [Agent 任务配置目标模式与权限](docs/stages/v2/05_Agent任务配置目标模式与权限.md) | `REVALIDATION_REQUIRED` | 完整标题、城市、场次、票档、金额和逐次确认 | +| 03 | [FakeTicket 场次票档与订单边界](docs/stages/v2/03_FakeTicket场次票档与订单边界.md) | `VERIFIED` | 2026-08-13:三场七票档、数量/实名/金额边界、订单摘要与支付停止页在 vivo Android 16 通过 | +| 04 | [FakeTicket 开售排队与异常场景](docs/stages/v2/04_FakeTicket开售排队与异常场景.md) | `VERIFIED` | 2026-08-13:开售/库存、排队三结果、八类异常、Debug 注入门禁及 vivo Android 16 真机 fixture 通过 | +| 05 | [Agent 任务配置目标模式与权限](docs/stages/v2/05_Agent任务配置目标模式与权限.md) | `READY` | 前置阶段已验证;完整标题、城市、场次、票档、金额和逐次确认待复验 | | 06 | [目标身份与 Accessibility 观测](docs/stages/v2/06_目标身份与Accessibility观测.md) | `REVALIDATION_REQUIRED` | 目标配置、包名/版本/签名与节点快照 | | 07 | [截图 OCR 与证据归一化](docs/stages/v2/07_截图OCR与证据归一化.md) | `REVALIDATION_REQUIRED` | 同帧 OCR、隐私和资源生命周期 | | 08 | [元素定位与页面状态](docs/stages/v2/08_元素定位与页面状态.md) | `REVALIDATION_REQUIRED` | 文本、区域、关系和置信度 | diff --git a/docs/images/test-results/stage-v2-03/01-reservation-selected.png b/docs/images/test-results/stage-v2-03/01-reservation-selected.png new file mode 100644 index 0000000..fac2489 Binary files /dev/null and b/docs/images/test-results/stage-v2-03/01-reservation-selected.png differ diff --git a/docs/images/test-results/stage-v2-03/02-order-summary.png b/docs/images/test-results/stage-v2-03/02-order-summary.png new file mode 100644 index 0000000..305200d Binary files /dev/null and b/docs/images/test-results/stage-v2-03/02-order-summary.png differ diff --git a/docs/images/test-results/stage-v2-03/03-payment-stop.png b/docs/images/test-results/stage-v2-03/03-payment-stop.png new file mode 100644 index 0000000..0a06d65 Binary files /dev/null and b/docs/images/test-results/stage-v2-03/03-payment-stop.png differ diff --git a/docs/images/test-results/stage-v2-03/evidence.md b/docs/images/test-results/stage-v2-03/evidence.md new file mode 100644 index 0000000..c2b3ed9 --- /dev/null +++ b/docs/images/test-results/stage-v2-03/evidence.md @@ -0,0 +1,17 @@ +# 阶段 03 真机证据 + +本目录只保存 FakeTicketApp 的脱敏 fixture 页面,不包含真实账号、实名资料、票务平台页面、OCR 原文或完整节点树。 + +- 设备:vivo V2453A +- Android:16(API 36) +- 流程:三亚预约选择 -> 模拟订单摘要 -> 支付停止页 +- 固定选择:2026-12-19 19:30、980 元票档、1 张、观演人 A fixture +- 金额:票款 980 元 + 服务费 10 元 = 合计 990 元 +- 安全结论:支付停止页无支付动作;支付只能由用户本人处理 +- 设备环境:截图右侧可见 vivo 系统 `com.vivo.upslide` 小窗控件,仅覆盖空白区域,未修改用户系统设置 + +文件: + +- `01-reservation-selected.png` +- `02-order-summary.png` +- `03-payment-stop.png` diff --git a/docs/images/test-results/stage-v2-04/01-queue-waiting.png b/docs/images/test-results/stage-v2-04/01-queue-waiting.png new file mode 100644 index 0000000..f371288 Binary files /dev/null and b/docs/images/test-results/stage-v2-04/01-queue-waiting.png differ diff --git a/docs/images/test-results/stage-v2-04/02-queue-timeout.png b/docs/images/test-results/stage-v2-04/02-queue-timeout.png new file mode 100644 index 0000000..03c2978 Binary files /dev/null and b/docs/images/test-results/stage-v2-04/02-queue-timeout.png differ diff --git a/docs/images/test-results/stage-v2-04/03-captcha-stop.png b/docs/images/test-results/stage-v2-04/03-captcha-stop.png new file mode 100644 index 0000000..28afe89 Binary files /dev/null and b/docs/images/test-results/stage-v2-04/03-captcha-stop.png differ diff --git a/docs/images/test-results/stage-v2-04/evidence.md b/docs/images/test-results/stage-v2-04/evidence.md new file mode 100644 index 0000000..ac3aa61 --- /dev/null +++ b/docs/images/test-results/stage-v2-04/evidence.md @@ -0,0 +1,17 @@ +# 阶段 04 真机证据 + +本目录只保存 FakeTicketApp 的脱敏 fixture 页面,不包含真实账号、实名资料、真实票务平台页面、OCR 原文或完整节点树。 + +- 设备:vivo V2453A +- Android:16(API 36) +- 屏幕:1260x2800,560 dpi +- 流程:Debug 场景注入 -> 排队等待 -> 排队超时 -> 验证码安全停止 +- 真机自动化:阶段 04 用例 3/3 通过;证据用例 1/1 通过 +- 安全结论:排队结果由本地 fixture 确定,不连接网络;验证码页只提供重置,不提供绕过或继续动作 +- 设备环境:截图右侧可见 vivo 系统悬浮控件,仅覆盖空白区域,未修改设备设置 + +文件与 SHA-256: + +- `01-queue-waiting.png`:`82d0be35e6f17181077e413ee2d55a0ad00f71fea6d44c4ba7132951c168bce4` +- `02-queue-timeout.png`:`a3289e3739bca3c3365c953839eabcb70d13dd613c790e9173ed49929ca20142` +- `03-captcha-stop.png`:`f1847508cfe354a779b94899013857de1d7ced830a2c25d9d82e55f6371b2c3d` diff --git "a/docs/stages/v2/03_FakeTicket\345\234\272\346\254\241\347\245\250\346\241\243\344\270\216\350\256\242\345\215\225\350\276\271\347\225\214.md" "b/docs/stages/v2/03_FakeTicket\345\234\272\346\254\241\347\245\250\346\241\243\344\270\216\350\256\242\345\215\225\350\276\271\347\225\214.md" index a710ee9..daf6cc8 100644 --- "a/docs/stages/v2/03_FakeTicket\345\234\272\346\254\241\347\245\250\346\241\243\344\270\216\350\256\242\345\215\225\350\276\271\347\225\214.md" +++ "b/docs/stages/v2/03_FakeTicket\345\234\272\346\254\241\347\245\250\346\241\243\344\270\216\350\256\242\345\215\225\350\276\271\347\225\214.md" @@ -1,6 +1,9 @@ # 阶段 03:FakeTicket 场次票档与订单边界 -- 状态:`READY` +- 状态:`VERIFIED` +- 负责人:Codex +- 开始时间:2026-08-13 +- 完成时间:2026-08-13 - 前置:阶段 02 `VERIFIED` ## 目标 @@ -14,3 +17,28 @@ ## 门禁 测试先行覆盖精确日期/时间、首选和有序后备票档、禁用确定按钮、必要费用、总额超限、实名缺失/歧义和支付页无支付动作。JVM、UI、Lint、assemble 与真机流程通过。 + +## 实施结果 + +- 三个可预约场次使用精确日期、时间和稳定 ID;七个票档按 `580 -> 780 -> 980 -> 1280 -> 1680 -> 1980 -> 2380` 固定排序。 +- 票档策略支持首选和有序后备;数量固定为 1 至 2,实名 fixture 数量必须与票数一致。 +- 模拟订单明确计算票款和每张 10 元服务费;测试总额上限为 4000 元,超过时禁止确认。 +- 预约页、订单摘要和支付停止页共享同一订单草稿;支付页删除支付动作,仅允许返回复核。 + +## 验证与证据 + +- TDD 红灯:`ReservationOrderPolicyTest` 首次因策略和 fixture 缺失编译失败;补最小领域实现后通过。 +- TDD 红灯:`ReservationFlowViewModelTest` 首次因状态层缺失编译失败;补最小 `StateFlow` ViewModel 后通过。 +- 真机首次失败:离屏高价票档不在节点树;增加稳定列表 ID 并滚动到节点。 +- 真机第二次失败:滚到列表尾后目标场次离屏;每个选择动作前滚动到对应稳定 ID。 +- 真机第三次失败:摘要文案重复“观演人”;收敛 fixture 显示名职责后通过。 +- `./gradlew :fake-ticket-app:testDebugUnitTest :fake-ticket-app:lintDebug :fake-ticket-app:assembleDebug`:退出码 0;JVM 84 个测试零失败,Lint 和 APK 构建成功。 +- `ANDROID_SERIAL=10AF5C1FCL001TZ ./gradlew :fake-ticket-app:connectedDebugAndroidTest`:退出码 0;vivo V2453A、Android 16/API 36,9 个测试零失败。 +- 支付安全负向:主导航无支付回调,支付停止页无 `ACTION_PAY` 节点;完整真机用例断言支付动作数量为 0。 +- 视觉证据:`docs/images/test-results/stage-v2-03/`;三张图只含脱敏 FakeTicket fixture。右侧 vivo 系统小窗仅覆盖空白区域,未修改设备设置。 + +## 限制与下一门禁 + +- 本阶段只验证 vivo V2453A Android 16/API 36,不外推到其他系统、ROM 或分辨率。 +- 本阶段未操作 AndroidAgent 或真实大麦;真实目标仍须等待阶段 15 及后续逐级验收。 +- 阶段 04 已在总览中成为下一次唯一 `READY`;本次不开始阶段 04。 diff --git "a/docs/stages/v2/04_FakeTicket\345\274\200\345\224\256\346\216\222\351\230\237\344\270\216\345\274\202\345\270\270\345\234\272\346\231\257.md" "b/docs/stages/v2/04_FakeTicket\345\274\200\345\224\256\346\216\222\351\230\237\344\270\216\345\274\202\345\270\270\345\234\272\346\231\257.md" index 2a2fb76..1bdb12b 100644 --- "a/docs/stages/v2/04_FakeTicket\345\274\200\345\224\256\346\216\222\351\230\237\344\270\216\345\274\202\345\270\270\345\234\272\346\231\257.md" +++ "b/docs/stages/v2/04_FakeTicket\345\274\200\345\224\256\346\216\222\351\230\237\344\270\216\345\274\202\345\270\270\345\234\272\346\231\257.md" @@ -1,6 +1,9 @@ # 阶段 04:FakeTicket 开售排队与异常场景 -- 状态:`REVALIDATION_REQUIRED` +- 状态:`VERIFIED` +- 负责人:Codex +- 开始时间:2026-08-13 +- 完成时间:2026-08-13 - 前置:阶段 03 `VERIFIED` ## 目标 @@ -14,3 +17,37 @@ ## 门禁 模型和 Compose 测试覆盖每个转换与重置;release 不暴露场景注入;排队 fixture 不依赖固定不可控网络。所有测试、Lint、assemble 和真机人工注入通过。 + +## 实现结果 + +- `FakeSaleClock`、七票档库存与场景控制器提供未开售、已开售、售罄及统一固定初态重置。 +- 排队 fixture 覆盖等待到成功、失败、超时三个确定性结果;失败/超时暴露 `page_queue_failed`,所有状态暴露 `text_queue_status`。 +- 独立页面覆盖网络错误、登录失效、风控、验证码和未知页;阻断场景使用带 `dialog_blocking` 与 `action_dismiss_dialog` 的真实 Compose 弹窗。 +- Debug 构建允许从测试 Intent 注入已知枚举;Release 构建策略拒绝全部场景注入,未知值在所有构建中均拒绝。 +- 全部 fixture 使用本地内存状态,不连接网络;每个场景均可经 `action_reset_scenario` 回到首页固定初态。 + +## 验证结果 + +工作目录均为仓库根目录。 + +- `./gradlew :fake-ticket-app:testDebugUnitTest :fake-ticket-app:testReleaseUnitTest :fake-ticket-app:lintDebug :fake-ticket-app:lintRelease :fake-ticket-app:assembleDebug :fake-ticket-app:assembleRelease`:退出码 0;Debug/Release 各 93 条单元测试通过,两种构建的 Lint 与 APK assemble 通过。 +- `ANDROID_SERIAL=emulator-5554 ./gradlew :fake-ticket-app:connectedDebugAndroidTest`:退出码 0;Pixel 6 AVD、Android 14,12/12 条 instrumented test 通过,包含横竖屏恢复与阶段 04 三条 Compose fixture 测试。 +- `ANDROID_SERIAL=10AF5C1FCL001TZ ./gradlew :fake-ticket-app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.yanbo.faketicket.FakeTicketScenarioFixtureTest`:退出码 0;vivo V2453A、Android 16/API 36,3/3 条阶段 04 真机测试通过。 +- 显式安装 app/test APK 后执行 `adb ... shell am instrument ... FakeTicketScenarioFixtureTest#capturesSelectedStage04Evidence`:退出码 0,`OK (1 test)`;验证 Debug 手工场景注入并保留选定截图。 +- Release 负向验证由 `ScenarioBuildPolicyTest` 在 `testReleaseUnitTest` 中执行:`BuildConfig.DEBUG=false` 时 `CAPTCHA` 等已知值仍返回 `null`。 + +## 失败修复 + +- vivo 首次安装在熄屏锁定状态返回 `INSTALL_FAILED_ABORTED: User rejected permissions`;解锁设备并由用户允许 USB 安装后,相同 APK 安装成功,随后真机用例通过。未绕过 Android 安全确认。 +- 初版阻断场景只是普通页面,Compose 契约测试因 `dialog_blocking` 不存在失败;改为真实 `AlertDialog` 后关闭与重置用例通过。 +- 首次全量模拟器回归有两条历史横屏用例触发 `performMeasureAndLayout called during measure layout`;日志确认恢复竖屏时系统 rotation 已变化但 Compose 仍在布局,在测试恢复 helper 加入 UI 空闲等待后 discovery 7/7、全量 12/12 通过。 + +## 证据与限制 + +- 脱敏截图与哈希见 [阶段证据](../../images/test-results/stage-v2-04/evidence.md)。 +- 本阶段只验收 FakeTicketApp fixture,不包含 AndroidAgent 自动化链路,也没有执行真实大麦识别或动作。 +- 真机结果仅代表 vivo V2453A、Android 16/API 36、1260x2800;Android 10 至 15 的兼容性仍待后续矩阵阶段验证。 + +## 下一阶段门禁 + +阶段 04 已 `VERIFIED`。阶段 05 可标为唯一 `READY`,但本任务在此停止,不自动开始阶段 05。 diff --git a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketDiscoveryFlowTest.kt b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketDiscoveryFlowTest.kt index 6c4886a..8d1f445 100644 --- a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketDiscoveryFlowTest.kt +++ b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketDiscoveryFlowTest.kt @@ -174,5 +174,6 @@ class FakeTicketDiscoveryFlowTest { ShellTestDevice.run("settings put system accelerometer_rotation $originalAuto") ShellTestDevice.run("settings put system user_rotation $originalRotation") ShellTestDevice.waitForRotation(originalRotation.toInt()) + ShellTestDevice.waitForUiStable() } } diff --git a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketReservationOrderFlowTest.kt b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketReservationOrderFlowTest.kt new file mode 100644 index 0000000..7e50fef --- /dev/null +++ b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketReservationOrderFlowTest.kt @@ -0,0 +1,108 @@ +package com.yanbo.faketicket + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.yanbo.faketicket.support.ShellTestDevice +import com.yanbo.faketicket.ui.components.FakeTicketTestTags +import com.yanbo.faketicket.ui.discovery.DiscoveryContent +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FakeTicketReservationOrderFlowTest { + @get:Rule + val composeRule = createEmptyComposeRule() + + @Before + fun launch() = ShellTestDevice.launchFakeTicket() + + @Test + fun exactReservationCreatesSummaryAndStopsWithoutPaymentAction() { + openReservationSelect() + + listOf( + "12月18日 周五 19:30", + "12月19日 周六 19:30", + "12月20日 周日 19:30", + ).forEach { composeRule.onNodeWithText(it).assertExists() } + listOf(580, 780, 980, 1280, 1680, 1980, 2380).forEach { price -> + composeRule.onNodeWithTag(FakeTicketTestTags.LIST_RESERVATION_OPTIONS) + .performScrollToNode(hasTestTag(FakeTicketTestTags.tier("tier-$price"))) + composeRule.onNodeWithText("¥$price").assertExists() + } + + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_CONFIRM_RESERVATION) + .assertIsNotEnabled() + composeRule.onNodeWithTag(FakeTicketTestTags.LIST_RESERVATION_OPTIONS) + .performScrollToNode(hasTestTag(FakeTicketTestTags.SESSION_2026_12_19_1930)) + composeRule.onNodeWithTag(FakeTicketTestTags.SESSION_2026_12_19_1930).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.LIST_RESERVATION_OPTIONS) + .performScrollToNode(hasTestTag(FakeTicketTestTags.TIER_980)) + composeRule.onNodeWithTag(FakeTicketTestTags.TIER_980).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_CONFIRM_RESERVATION) + .assertIsNotEnabled() + composeRule.onNodeWithTag(FakeTicketTestTags.LIST_RESERVATION_OPTIONS) + .performScrollToNode(hasTestTag(FakeTicketTestTags.ATTENDEE_FIXTURE_A)) + composeRule.onNodeWithTag(FakeTicketTestTags.ATTENDEE_FIXTURE_A).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_CONFIRM_RESERVATION) + .assertIsEnabled() + val screenshots = mutableListOf( + ShellTestDevice.capturePrivatePng( + fileName = "01-reservation-selected.png", + privateDirectoryName = "stage-v2-03", + ), + ) + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_CONFIRM_RESERVATION) + .performClick() + + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_ORDER_CONFIRM).assertIsDisplayed() + composeRule.onNodeWithText("12月19日 周六 19:30").assertExists() + composeRule.onNodeWithText("票款 ¥980").assertExists() + composeRule.onNodeWithText("服务费 ¥10").assertExists() + composeRule.onNodeWithText("合计 ¥990").assertExists() + composeRule.onNodeWithText("观演人 A").assertExists() + screenshots += ShellTestDevice.capturePrivatePng( + fileName = "02-order-summary.png", + privateDirectoryName = "stage-v2-03", + ) + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_SUBMIT_ORDER).performClick() + + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_PAYMENT).assertIsDisplayed() + composeRule.onNodeWithText("支付请由用户本人处理").assertIsDisplayed() + composeRule.onAllNodesWithTag(FakeTicketTestTags.ACTION_PAY).assertCountEquals(0) + screenshots += ShellTestDevice.capturePrivatePng( + fileName = "03-payment-stop.png", + privateDirectoryName = "stage-v2-03", + ) + ShellTestDevice.exportEvidence(screenshots, directoryName = "stage-v2-03-selected") + } + + private fun openReservationSelect() { + composeRule.onNodeWithTag(FakeTicketTestTags.INPUT_HOME_SEARCH) + .performTextInput(DiscoveryContent.performanceTitle) + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_HOME_SEARCH).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_SEARCH_RESULTS).assertIsDisplayed() + ShellTestDevice.waitForKeyboardHidden() + composeRule.onNode( + hasTestTag(FakeTicketTestTags.CARD_TARGET_PERFORMANCE) and + hasAnyDescendant(hasTestTag(FakeTicketTestTags.CITY_SANYA)), + ).assertExists() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_CITY_RESERVE).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_RESERVE_OR_BUY).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_RESERVATION_SELECT).assertIsDisplayed() + } +} diff --git a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketScenarioFixtureTest.kt b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketScenarioFixtureTest.kt new file mode 100644 index 0000000..3db4f1a --- /dev/null +++ b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/FakeTicketScenarioFixtureTest.kt @@ -0,0 +1,110 @@ +package com.yanbo.faketicket + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.yanbo.faketicket.support.ShellTestDevice +import com.yanbo.faketicket.ui.components.FakeTicketTestTags +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FakeTicketScenarioFixtureTest { + @get:Rule + val composeRule = createEmptyComposeRule() + + @Test + fun saleAndQueueFixturesTransitionAndResetDeterministically() { + launchScenario("WAIT_SALE", FakeTicketTestTags.PAGE_WAIT_SALE, "等待开售") + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_ADVANCE_SALE).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_BUY_READY).assertIsDisplayed() + resetToHome() + + launchScenario("BUY_READY", FakeTicketTestTags.PAGE_BUY_READY, "可购买") + resetToHome() + + listOf( + Triple("QUEUE_SUCCESS", "排队成功", FakeTicketTestTags.PAGE_QUEUE), + Triple("QUEUE_FAILURE", "排队失败", FakeTicketTestTags.PAGE_QUEUE_FAILED), + Triple("QUEUE_TIMEOUT", "排队超时", FakeTicketTestTags.PAGE_QUEUE_FAILED), + ).forEach { (scenario, finalText, finalPageTag) -> + launchScenario(scenario, FakeTicketTestTags.PAGE_QUEUE, "排队中") + composeRule.onNodeWithTag(FakeTicketTestTags.TEXT_QUEUE_STATUS).assertIsDisplayed() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_RESOLVE_QUEUE).performClick() + composeRule.onNodeWithTag(finalPageTag).assertIsDisplayed() + composeRule.onNodeWithText(finalText).assertIsDisplayed() + resetToHome() + } + } + + @Test + fun everyStopFixtureHasUniquePageAndControlledReset() { + listOf( + Triple("SOLD_OUT", FakeTicketTestTags.PAGE_SOLD_OUT, "已售罄"), + Triple("NETWORK_ERROR", FakeTicketTestTags.PAGE_NETWORK_ERROR, "网络异常"), + Triple("LOGIN_REQUIRED", FakeTicketTestTags.PAGE_LOGIN_REQUIRED, "登录已失效"), + Triple("RISK_CONTROL", FakeTicketTestTags.PAGE_RISK_CONTROL, "触发风控"), + Triple("CAPTCHA", FakeTicketTestTags.PAGE_CAPTCHA, "需要验证码"), + Triple("UNKNOWN_PAGE", FakeTicketTestTags.PAGE_UNKNOWN, "未知页面"), + ).forEach { (scenario, pageTag, text) -> + launchScenario(scenario, pageTag, text) + resetToHome() + } + + launchScenario("BLOCKING_DIALOG", FakeTicketTestTags.PAGE_BLOCKING_DIALOG, "阻断弹窗场景") + composeRule.onNodeWithTag(FakeTicketTestTags.DIALOG_BLOCKING).assertIsDisplayed() + composeRule.onNodeWithText("存在阻断弹窗").assertIsDisplayed() + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_DISMISS_DIALOG).performClick() + check( + composeRule.onAllNodesWithTag(FakeTicketTestTags.DIALOG_BLOCKING) + .fetchSemanticsNodes().isEmpty(), + ) { "Blocking dialog should be dismissed" } + resetToHome() + } + + @Test + fun capturesSelectedStage04Evidence() { + val screenshots = mutableListOf( + run { + launchScenario("QUEUE_TIMEOUT", FakeTicketTestTags.PAGE_QUEUE, "排队中") + ShellTestDevice.capturePrivatePng( + fileName = "01-queue-waiting.png", + privateDirectoryName = "stage-v2-04", + ) + }, + ) + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_RESOLVE_QUEUE).performClick() + composeRule.onNodeWithText("排队超时").assertIsDisplayed() + screenshots += ShellTestDevice.capturePrivatePng( + fileName = "02-queue-timeout.png", + privateDirectoryName = "stage-v2-04", + ) + resetToHome() + + launchScenario("CAPTCHA", FakeTicketTestTags.PAGE_CAPTCHA, "需要验证码") + screenshots += ShellTestDevice.capturePrivatePng( + fileName = "03-captcha-stop.png", + privateDirectoryName = "stage-v2-04", + ) + ShellTestDevice.exportEvidence( + privateScreenshots = screenshots, + directoryName = "stage-v2-04-selected", + ) + } + + private fun launchScenario(scenario: String, pageTag: String, text: String) { + ShellTestDevice.launchFakeTicket(scenario) + composeRule.onNodeWithTag(pageTag).assertIsDisplayed() + composeRule.onNodeWithText(text).assertIsDisplayed() + } + + private fun resetToHome() { + composeRule.onNodeWithTag(FakeTicketTestTags.ACTION_RESET_SCENARIO).performClick() + composeRule.onNodeWithTag(FakeTicketTestTags.PAGE_HOME).assertIsDisplayed() + } +} diff --git a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/support/ShellTestDevice.kt b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/support/ShellTestDevice.kt index 5adc042..1d94499 100644 --- a/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/support/ShellTestDevice.kt +++ b/fake-ticket-app/src/androidTest/java/com/yanbo/faketicket/support/ShellTestDevice.kt @@ -16,12 +16,13 @@ object ShellTestDevice { } } - fun launchFakeTicket() { + fun launchFakeTicket(scenario: String? = null) { + val scenarioArgument = scenario?.let { " --es scenario $it" }.orEmpty() val output = run( "am start -W -a android.intent.action.MAIN " + "-c android.intent.category.LAUNCHER -f 0x10008000 " + "--windowingMode 1 " + - "-n com.yanbo.faketicket/.MainActivity", + "-n com.yanbo.faketicket/.MainActivity" + scenarioArgument, ) check("Status: ok" in output) { "FakeTicket launch failed: $output" } waitForFakeTicketForeground() @@ -37,11 +38,14 @@ object ShellTestDevice { error("Display did not reach rotation $expectedRotation") } - fun capturePrivatePng(fileName: String): File { + fun capturePrivatePng( + fileName: String, + privateDirectoryName: String = "stage-v2-02", + ): File { waitForUiStable() waitForFakeTicketForeground() val instrumentation = InstrumentationRegistry.getInstrumentation() - val directory = File(instrumentation.targetContext.filesDir, "stage-v2-02") + val directory = File(instrumentation.targetContext.filesDir, privateDirectoryName) check(directory.exists() || directory.mkdirs()) { "Unable to create private screenshot directory: $directory" } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/MainActivity.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/MainActivity.kt index f86ae4f..e333d0b 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/MainActivity.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -20,6 +20,8 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.lifecycle.viewmodel.compose.viewModel import com.yanbo.faketicket.navigation.FakeTicketNavHost import com.yanbo.faketicket.scenario.FakeTicketScenario +import com.yanbo.faketicket.scenario.ScenarioBuildPolicy +import com.yanbo.faketicket.scenario.ScenarioInjectionPolicy import com.yanbo.faketicket.ui.FakeTicketViewModel import com.yanbo.faketicket.ui.components.FakeTicketTestTags @@ -36,11 +38,12 @@ class MainActivity : ComponentActivity() { setContent { val viewModel: FakeTicketViewModel = viewModel() - val scenarioState by viewModel.scenarioState.collectAsState() - pendingScenario?.let { scenario -> - viewModel.setScenario(scenario) - pendingScenario = null + LaunchedEffect(pendingScenario) { + pendingScenario?.let { scenario -> + viewModel.setScenario(scenario) + pendingScenario = null + } } Scaffold( @@ -62,9 +65,6 @@ class MainActivity : ComponentActivity() { private fun handleIntent(intent: Intent?) { intent ?: return val scenarioName = intent.getStringExtra("scenario") ?: return - try { - pendingScenario = FakeTicketScenario.valueOf(scenarioName.uppercase()) - } catch (_: IllegalArgumentException) { - } + pendingScenario = ScenarioInjectionPolicy(ScenarioBuildPolicy.allowInjection).parse(scenarioName) } -} \ No newline at end of file +} diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/data/InventoryStore.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/data/InventoryStore.kt index 2531491..5b230a9 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/data/InventoryStore.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/data/InventoryStore.kt @@ -5,10 +5,9 @@ import kotlinx.coroutines.sync.withLock class InventoryStore { private val mutex = Mutex() - private val inventory = mutableMapOf( - "tier-580" to 2, - "tier-880" to 2 - ) + private val initialInventory = listOf(580, 780, 980, 1280, 1680, 1980, 2380) + .associate { price -> "tier-$price" to 2 } + private val inventory = initialInventory.toMutableMap() suspend fun getStock(tierId: String): Int = mutex.withLock { inventory[tierId] ?: 0 @@ -29,8 +28,12 @@ class InventoryStore { } suspend fun reset() = mutex.withLock { - inventory["tier-580"] = 2 - inventory["tier-880"] = 2 + inventory.clear() + inventory.putAll(initialInventory) + } + + suspend fun clear() = mutex.withLock { + inventory.keys.forEach { tierId -> inventory[tierId] = 0 } } fun getAll(): Map = inventory.toMap() diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/model/Models.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/model/Models.kt index fd0610f..3eb366f 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/model/Models.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/model/Models.kt @@ -36,8 +36,3 @@ data class FakeOrder( val quantity: Int, val totalPriceYuan: Int ) - -data class PaymentResult( - val success: Boolean, - val orderId: String -) diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicy.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicy.kt new file mode 100644 index 0000000..16e95c3 --- /dev/null +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicy.kt @@ -0,0 +1,172 @@ +package com.yanbo.faketicket.domain.reservation + +data class ReservationSession( + val id: String, + val dateIso: String, + val time24Hour: String, + val displayText: String, +) + +data class ReservationTicketTier( + val id: String, + val priceYuan: Int, +) + +data class AttendeeProfile( + val id: String, + val displayName: String, +) + +data class TicketTierPreference( + val preferredTierId: String, + val backupTierIds: List, +) + +data class ReservationSelection( + val sessionId: String? = null, + val tierId: String? = null, + val quantity: Int = 1, + val attendeeProfileIds: List = emptyList(), +) + +data class ReservationOrderDraft( + val performanceTitle: String, + val session: ReservationSession, + val tier: ReservationTicketTier, + val quantity: Int, + val attendeeProfiles: List, + val serviceFeeYuan: Int, +) { + val ticketSubtotalYuan: Int = tier.priceYuan * quantity + val totalPriceYuan: Int = ticketSubtotalYuan + serviceFeeYuan +} + +enum class ReservationBlockReason { + SESSION_REQUIRED, + SESSION_INVALID, + TIER_REQUIRED, + TIER_INVALID, + QUANTITY_OUT_OF_RANGE, + ATTENDEE_REQUIRED, + ATTENDEE_AMBIGUOUS, + TOTAL_LIMIT_EXCEEDED, +} + +data class ReservationEvaluation( + val canConfirm: Boolean, + val reason: ReservationBlockReason?, + val totalPriceYuan: Int?, +) + +object ReservationFixture { + const val performanceTitle = "三亚·阳光 三亚 嘉年华2026周杰伦演唱会" + const val serviceFeePerTicketYuan = 10 + const val maximumTotalYuan = 4000 + + val sessions = listOf( + ReservationSession( + id = "session-2026-12-18-1930", + dateIso = "2026-12-18", + time24Hour = "19:30", + displayText = "12月18日 周五 19:30", + ), + ReservationSession( + id = "session-2026-12-19-1930", + dateIso = "2026-12-19", + time24Hour = "19:30", + displayText = "12月19日 周六 19:30", + ), + ReservationSession( + id = "session-2026-12-20-1930", + dateIso = "2026-12-20", + time24Hour = "19:30", + displayText = "12月20日 周日 19:30", + ), + ) + + val ticketTiers = listOf(580, 780, 980, 1280, 1680, 1980, 2380).map { price -> + ReservationTicketTier(id = "tier-$price", priceYuan = price) + } + + val attendeeProfiles = listOf( + AttendeeProfile(id = "attendee-fixture-a", displayName = "A"), + AttendeeProfile(id = "attendee-fixture-b", displayName = "B"), + ) +} + +class ReservationOrderPolicy( + private val fixture: ReservationFixture = ReservationFixture, +) { + fun matchSession(dateIso: String, time24Hour: String): ReservationSession? = + fixture.sessions.singleOrNull { it.dateIso == dateIso && it.time24Hour == time24Hour } + + fun resolveTier( + preference: TicketTierPreference, + availableTierIds: Set, + ): ReservationTicketTier? = + sequenceOf(preference.preferredTierId) + .plus(preference.backupTierIds.asSequence()) + .mapNotNull { tierId -> fixture.ticketTiers.singleOrNull { it.id == tierId } } + .firstOrNull { it.id in availableTierIds } + + fun evaluate(selection: ReservationSelection): ReservationEvaluation { + val sessionId = selection.sessionId + ?: return blocked(ReservationBlockReason.SESSION_REQUIRED) + if (fixture.sessions.none { it.id == sessionId }) { + return blocked(ReservationBlockReason.SESSION_INVALID) + } + + val tierId = selection.tierId + ?: return blocked(ReservationBlockReason.TIER_REQUIRED) + val tier = fixture.ticketTiers.singleOrNull { it.id == tierId } + ?: return blocked(ReservationBlockReason.TIER_INVALID) + + if (selection.quantity !in 1..2) { + return blocked(ReservationBlockReason.QUANTITY_OUT_OF_RANGE) + } + if (selection.attendeeProfileIds.isEmpty()) { + return blocked(ReservationBlockReason.ATTENDEE_REQUIRED) + } + val selectedProfiles = selection.attendeeProfileIds.mapNotNull { profileId -> + fixture.attendeeProfiles.singleOrNull { it.id == profileId } + } + if ( + selectedProfiles.size != selection.quantity || + selectedProfiles.size != selection.attendeeProfileIds.distinct().size + ) { + return blocked(ReservationBlockReason.ATTENDEE_AMBIGUOUS) + } + + val total = (tier.priceYuan + fixture.serviceFeePerTicketYuan) * selection.quantity + if (total > fixture.maximumTotalYuan) { + return blocked(ReservationBlockReason.TOTAL_LIMIT_EXCEEDED, total) + } + return ReservationEvaluation(canConfirm = true, reason = null, totalPriceYuan = total) + } + + fun createDraft(selection: ReservationSelection): ReservationOrderDraft? { + if (!evaluate(selection).canConfirm) return null + val session = fixture.sessions.single { it.id == selection.sessionId } + val tier = fixture.ticketTiers.single { it.id == selection.tierId } + val attendees = selection.attendeeProfileIds.map { profileId -> + fixture.attendeeProfiles.single { it.id == profileId } + } + return ReservationOrderDraft( + performanceTitle = fixture.performanceTitle, + session = session, + tier = tier, + quantity = selection.quantity, + attendeeProfiles = attendees, + serviceFeeYuan = fixture.serviceFeePerTicketYuan * selection.quantity, + ) + } + + private fun blocked( + reason: ReservationBlockReason, + totalPriceYuan: Int? = null, + ) = ReservationEvaluation( + canConfirm = false, + reason = reason, + totalPriceYuan = totalPriceYuan, + ) +} diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketNavHost.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketNavHost.kt index 5f50e09..1944a39 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketNavHost.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketNavHost.kt @@ -11,13 +11,18 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel import com.yanbo.faketicket.scenario.FakeTicketScenario +import com.yanbo.faketicket.scenario.ScenarioState import com.yanbo.faketicket.ui.FakeTicketViewModel +import com.yanbo.faketicket.ui.components.FakeTicketTestTags import com.yanbo.faketicket.ui.discovery.HomeDiscoveryScreen import com.yanbo.faketicket.ui.discovery.PerformanceDetailDiscoveryScreen import com.yanbo.faketicket.ui.discovery.ReservationSelectDiscoveryScreen import com.yanbo.faketicket.ui.discovery.SearchResultsDiscoveryScreen +import com.yanbo.faketicket.ui.reservation.ReservationFlowViewModel import com.yanbo.faketicket.ui.screens.BuyReadyScreen +import com.yanbo.faketicket.ui.screens.BlockingDialogFixtureScreen import com.yanbo.faketicket.ui.screens.LoginRequiredScreen import com.yanbo.faketicket.ui.screens.NetworkErrorScreen import com.yanbo.faketicket.ui.screens.OrderConfirmScreen @@ -26,6 +31,8 @@ import com.yanbo.faketicket.ui.screens.SoldOutScreen import com.yanbo.faketicket.ui.screens.SuccessScreen import com.yanbo.faketicket.ui.screens.TicketSelectScreen import com.yanbo.faketicket.ui.screens.WaitSaleScreen +import com.yanbo.faketicket.ui.screens.QueueFixtureScreen +import com.yanbo.faketicket.ui.screens.StopFixtureScreen @Composable fun FakeTicketNavHost( @@ -33,13 +40,29 @@ fun FakeTicketNavHost( navController: NavHostController = rememberNavController(), viewModel: FakeTicketViewModel? = null ) { - val currentRoute = viewModel?.let { vm -> + val reservationViewModel: ReservationFlowViewModel = composeViewModel() + val reservationState by reservationViewModel.uiState.collectAsState() + val scenarioState = viewModel?.let { vm -> val state by vm.scenarioState.collectAsState() - state.scenario - } ?: FakeTicketScenario.NORMAL + state + } ?: ScenarioState() + val currentScenario = scenarioState.scenario + val resetScenarioAndReturnHome = { + viewModel?.resetScenario() + navController.navigate(FakeTicketRoute.HOME) { + popUpTo(FakeTicketRoute.HOME) { inclusive = true } + launchSingleTop = true + } + } - LaunchedEffect(currentRoute) { - when (currentRoute) { + LaunchedEffect(currentScenario) { + when (currentScenario) { + FakeTicketScenario.WAIT_SALE -> navController.navigate(FakeTicketRoute.WAIT_SALE) + FakeTicketScenario.BUY_READY -> navController.navigate(FakeTicketRoute.BUY_READY) + FakeTicketScenario.QUEUE_SUCCESS, + FakeTicketScenario.QUEUE_FAILURE, + FakeTicketScenario.QUEUE_TIMEOUT, + -> navController.navigate(FakeTicketRoute.QUEUE) FakeTicketScenario.SOLD_OUT -> { navController.navigate(FakeTicketRoute.SOLD_OUT) { popUpTo(FakeTicketRoute.HOME) { inclusive = true } @@ -55,12 +78,13 @@ fun FakeTicketNavHost( popUpTo(FakeTicketRoute.HOME) { inclusive = true } } } + FakeTicketScenario.BLOCKING_DIALOG -> navController.navigate(FakeTicketRoute.BLOCKING_DIALOG) + FakeTicketScenario.RISK_CONTROL -> navController.navigate(FakeTicketRoute.RISK_CONTROL) + FakeTicketScenario.CAPTCHA -> navController.navigate(FakeTicketRoute.CAPTCHA) FakeTicketScenario.UNKNOWN_PAGE -> { - navController.navigate(FakeTicketRoute.SOLD_OUT) { - popUpTo(FakeTicketRoute.HOME) { inclusive = true } - } + navController.navigate(FakeTicketRoute.UNKNOWN) } - FakeTicketScenario.NORMAL -> {} + FakeTicketScenario.NORMAL -> Unit } } @@ -97,18 +121,26 @@ fun FakeTicketNavHost( ) } composable(FakeTicketRoute.RESERVATION_SELECT) { - ReservationSelectDiscoveryScreen() + ReservationSelectDiscoveryScreen( + state = reservationState, + onEvent = reservationViewModel::onEvent, + onConfirm = { + if (reservationViewModel.uiState.value.orderDraft != null) { + navController.navigate(FakeTicketRoute.ORDER_CONFIRM) + } + }, + ) } composable(FakeTicketRoute.WAIT_SALE) { WaitSaleScreen( - onBuyReady = { navController.navigate(FakeTicketRoute.BUY_READY) }, - onBack = { navController.popBackStack() } + onAdvanceSale = { viewModel?.advanceSaleFixture() }, + onReset = resetScenarioAndReturnHome, ) } composable(FakeTicketRoute.BUY_READY) { BuyReadyScreen( onBuy = { navController.navigate(FakeTicketRoute.TICKET_SELECT) }, - onBack = { navController.popBackStack() } + onReset = resetScenarioAndReturnHome, ) } composable(FakeTicketRoute.TICKET_SELECT) { @@ -119,13 +151,14 @@ fun FakeTicketNavHost( } composable(FakeTicketRoute.ORDER_CONFIRM) { OrderConfirmScreen( + draft = reservationState.orderDraft, onSubmit = { navController.navigate(FakeTicketRoute.PAYMENT) }, onBack = { navController.popBackStack() } ) } composable(FakeTicketRoute.PAYMENT) { PaymentScreen( - onPay = { navController.navigate(FakeTicketRoute.SUCCESS) }, + draft = reservationState.orderDraft, onBack = { navController.popBackStack() } ) } @@ -137,13 +170,52 @@ fun FakeTicketNavHost( ) } composable(FakeTicketRoute.SOLD_OUT) { - SoldOutScreen() + SoldOutScreen(onReset = resetScenarioAndReturnHome) } composable(FakeTicketRoute.NETWORK_ERROR) { - NetworkErrorScreen() + NetworkErrorScreen(onReset = resetScenarioAndReturnHome) } composable(FakeTicketRoute.LOGIN_REQUIRED) { - LoginRequiredScreen() + LoginRequiredScreen(onReset = resetScenarioAndReturnHome) + } + composable(FakeTicketRoute.QUEUE) { + QueueFixtureScreen( + queueState = scenarioState.queueState, + onResolve = { viewModel?.resolveQueueFixture() }, + onReset = resetScenarioAndReturnHome, + ) + } + composable(FakeTicketRoute.BLOCKING_DIALOG) { + BlockingDialogFixtureScreen( + dialogVisible = scenarioState.dialogVisible, + message = scenarioState.dialogMessage.orEmpty(), + onDismiss = { viewModel?.dismissBlockingDialogFixture() }, + onReset = resetScenarioAndReturnHome, + ) + } + composable(FakeTicketRoute.RISK_CONTROL) { + StopFixtureScreen( + pageTag = FakeTicketTestTags.PAGE_RISK_CONTROL, + title = "触发风控", + detail = "停止所有自动操作", + onReset = resetScenarioAndReturnHome, + ) + } + composable(FakeTicketRoute.CAPTCHA) { + StopFixtureScreen( + pageTag = FakeTicketTestTags.PAGE_CAPTCHA, + title = "需要验证码", + detail = "验证码只能由用户本人处理", + onReset = resetScenarioAndReturnHome, + ) + } + composable(FakeTicketRoute.UNKNOWN) { + StopFixtureScreen( + pageTag = FakeTicketTestTags.PAGE_UNKNOWN, + title = "未知页面", + detail = "无法确定页面状态,停止操作", + onReset = resetScenarioAndReturnHome, + ) } } } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketPage.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketPage.kt index 22c6b26..5539eea 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketPage.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketPage.kt @@ -13,5 +13,10 @@ enum class FakeTicketPage(val route: String, val tag: String) { SUCCESS("success", "page_success"), SOLD_OUT("sold_out", "page_sold_out"), NETWORK_ERROR("network_error", "page_network_error"), - LOGIN_REQUIRED("login_required", "page_login_required"); + LOGIN_REQUIRED("login_required", "page_login_required"), + QUEUE("queue", "page_queue"), + BLOCKING_DIALOG("blocking_dialog", "page_blocking_dialog"), + RISK_CONTROL("risk_control", "page_risk_control"), + CAPTCHA("captcha", "page_captcha"), + UNKNOWN("unknown", "page_unknown"); } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketRoute.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketRoute.kt index 01e6886..97735c0 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketRoute.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/navigation/FakeTicketRoute.kt @@ -16,6 +16,11 @@ object FakeTicketRoute { const val SOLD_OUT = "sold_out" const val NETWORK_ERROR = "network_error" const val LOGIN_REQUIRED = "login_required" + const val QUEUE = "queue" + const val BLOCKING_DIALOG = "blocking_dialog" + const val RISK_CONTROL = "risk_control" + const val CAPTCHA = "captcha" + const val UNKNOWN = "unknown" const val SEARCH_QUERY = "query" const val SEARCH_RESULTS_PATTERN = "$SEARCH_RESULTS?$SEARCH_QUERY={$SEARCH_QUERY}" diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/FakeTicketScenario.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/FakeTicketScenario.kt index ef23b3f..637f23a 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/FakeTicketScenario.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/FakeTicketScenario.kt @@ -1,14 +1,55 @@ package com.yanbo.faketicket.scenario +import com.yanbo.faketicket.BuildConfig + enum class FakeTicketScenario { NORMAL, + WAIT_SALE, + BUY_READY, + QUEUE_SUCCESS, + QUEUE_FAILURE, + QUEUE_TIMEOUT, SOLD_OUT, NETWORK_ERROR, + BLOCKING_DIALOG, LOGIN_REQUIRED, + RISK_CONTROL, + CAPTCHA, UNKNOWN_PAGE } +enum class QueueState { + IDLE, + WAITING, + SUCCESS, + FAILURE, + TIMEOUT, +} + +enum class QueueFixtureResult(val finalState: QueueState) { + SUCCESS(QueueState.SUCCESS), + FAILURE(QueueState.FAILURE), + TIMEOUT(QueueState.TIMEOUT), +} + data class ScenarioState( val scenario: FakeTicketScenario = FakeTicketScenario.NORMAL, - val dialogVisible: Boolean = false + val dialogVisible: Boolean = false, + val dialogMessage: String? = null, + val queueState: QueueState = QueueState.IDLE, + val pendingQueueResult: QueueFixtureResult? = null, ) + +class ScenarioInjectionPolicy(private val allowInjection: Boolean) { + fun parse(rawScenario: String?): FakeTicketScenario? { + if (!allowInjection || rawScenario == null) return null + return FakeTicketScenario.entries.singleOrNull { + it.name.equals(rawScenario.trim(), ignoreCase = true) + } + } +} + +object ScenarioBuildPolicy { + val allowInjection: Boolean + get() = BuildConfig.DEBUG +} diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/ScenarioController.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/ScenarioController.kt index 419f459..4169a45 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/ScenarioController.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/scenario/ScenarioController.kt @@ -19,25 +19,77 @@ class ScenarioController( val scenarioState: StateFlow = _scenarioState.asStateFlow() fun setScenario(scenario: FakeTicketScenario) { + when (scenario) { + FakeTicketScenario.WAIT_SALE -> saleClock.reset() + FakeTicketScenario.BUY_READY -> advanceClockToSaleTime() + FakeTicketScenario.SOLD_OUT -> kotlinx.coroutines.runBlocking { inventoryStore.clear() } + FakeTicketScenario.QUEUE_SUCCESS -> { + startQueue(QueueFixtureResult.SUCCESS) + return + } + FakeTicketScenario.QUEUE_FAILURE -> { + startQueue(QueueFixtureResult.FAILURE) + return + } + FakeTicketScenario.QUEUE_TIMEOUT -> { + startQueue(QueueFixtureResult.TIMEOUT) + return + } + else -> Unit + } _scenarioState.update { - it.copy(scenario = scenario, dialogVisible = false) + it.copy( + scenario = scenario, + dialogVisible = scenario == FakeTicketScenario.BLOCKING_DIALOG, + dialogMessage = if (scenario == FakeTicketScenario.BLOCKING_DIALOG) { + "存在阻断弹窗" + } else { + null + }, + queueState = QueueState.IDLE, + pendingQueueResult = null, + ) } } fun showDialog(message: String = "加载中...") { _scenarioState.update { - it.copy(dialogVisible = true) + it.copy(dialogVisible = true, dialogMessage = message) } } fun dismissDialog() { _scenarioState.update { - it.copy(dialogVisible = false) + it.copy(dialogVisible = false, dialogMessage = null) + } + } + + fun startQueue(result: QueueFixtureResult) { + _scenarioState.update { + it.copy( + scenario = when (result) { + QueueFixtureResult.SUCCESS -> FakeTicketScenario.QUEUE_SUCCESS + QueueFixtureResult.FAILURE -> FakeTicketScenario.QUEUE_FAILURE + QueueFixtureResult.TIMEOUT -> FakeTicketScenario.QUEUE_TIMEOUT + }, + queueState = QueueState.WAITING, + pendingQueueResult = result, + dialogVisible = false, + dialogMessage = null, + ) + } + } + + fun resolveQueue() { + _scenarioState.update { state -> + state.pendingQueueResult?.let { result -> + state.copy(queueState = result.finalState) + } ?: state } } fun getCurrentSaleState(): SaleState { - val hasInventory = true + val hasInventory = inventoryStore.getAll().values.any { it > 0 } return saleStateResolver.resolve(saleClock.currentTimeMillis, hasInventory) } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketUiState.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketUiState.kt index aac4a6b..24102b7 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketUiState.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketUiState.kt @@ -2,7 +2,6 @@ package com.yanbo.faketicket.ui import com.yanbo.faketicket.domain.model.Concert import com.yanbo.faketicket.domain.model.FakeOrder -import com.yanbo.faketicket.domain.model.PaymentResult data class FakeTicketUiState( val currentRoute: String = "home", @@ -12,7 +11,6 @@ data class FakeTicketUiState( val selectedTierId: String? = null, val quantity: Int = 1, val order: FakeOrder? = null, - val paymentResult: PaymentResult? = null, val errorMessage: String? = null ) @@ -28,7 +26,6 @@ sealed interface FakeTicketUiEvent { data object IncreaseQuantity : FakeTicketUiEvent data object ConfirmTicket : FakeTicketUiEvent data object SubmitOrder : FakeTicketUiEvent - data object Pay : FakeTicketUiEvent data object BackHome : FakeTicketUiEvent data object Back : FakeTicketUiEvent } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketViewModel.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketViewModel.kt index 57a0a5f..84fe37b 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketViewModel.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/FakeTicketViewModel.kt @@ -6,7 +6,6 @@ import com.yanbo.faketicket.data.InMemoryTicketRepository import com.yanbo.faketicket.data.InventoryStore import com.yanbo.faketicket.data.TicketRepository import com.yanbo.faketicket.domain.model.OrderDraft -import com.yanbo.faketicket.domain.model.PaymentResult import com.yanbo.faketicket.scenario.FakeTicketScenario import com.yanbo.faketicket.scenario.ScenarioController import com.yanbo.faketicket.scenario.ScenarioState @@ -40,7 +39,6 @@ class FakeTicketViewModel( FakeTicketUiEvent.IncreaseQuantity -> increaseQuantity() FakeTicketUiEvent.ConfirmTicket -> confirmTicket() FakeTicketUiEvent.SubmitOrder -> submitOrder() - FakeTicketUiEvent.Pay -> pay() FakeTicketUiEvent.BackHome -> backHome() FakeTicketUiEvent.Back -> back() } @@ -186,21 +184,6 @@ class FakeTicketViewModel( } } - private fun pay() { - val order = _uiState.value.order - if (order == null) { - _uiState.update { it.copy(errorMessage = "没有待支付的订单") } - return - } - _uiState.update { - it.copy( - paymentResult = PaymentResult(success = true, orderId = order.orderId), - currentRoute = "success", - errorMessage = null - ) - } - } - private fun backHome() { _uiState.update { FakeTicketUiState() @@ -229,4 +212,17 @@ class FakeTicketViewModel( fun resetScenario() { scenarioController.reset() } + + fun advanceSaleFixture() { + scenarioController.advanceClockToSaleTime() + scenarioController.setScenario(FakeTicketScenario.BUY_READY) + } + + fun resolveQueueFixture() { + scenarioController.resolveQueue() + } + + fun dismissBlockingDialogFixture() { + scenarioController.dismissDialog() + } } diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/components/FakeTicketTestTags.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/components/FakeTicketTestTags.kt index 80c3dba..3e1c85e 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/components/FakeTicketTestTags.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/components/FakeTicketTestTags.kt @@ -16,7 +16,20 @@ object FakeTicketTestTags { const val TEXT_DATE_RANGE = "text_date_range" const val ACTION_RESERVE_OR_BUY = "action_reserve_or_buy" const val PAGE_RESERVATION_SELECT = "page_reservation_select" + const val LIST_RESERVATION_OPTIONS = "list_reservation_options" const val ACTION_CONFIRM_RESERVATION = "action_confirm_reservation" + const val SESSION_2026_12_18_1930 = "session_2026_12_18_1930" + const val SESSION_2026_12_19_1930 = "session_2026_12_19_1930" + const val SESSION_2026_12_20_1930 = "session_2026_12_20_1930" + const val TIER_580 = "tier_580" + const val TIER_780 = "tier_780" + const val TIER_980 = "tier_980" + const val TIER_1280 = "tier_1280" + const val TIER_1680 = "tier_1680" + const val TIER_1980 = "tier_1980" + const val TIER_2380 = "tier_2380" + const val ATTENDEE_FIXTURE_A = "attendee_fixture_a" + const val ATTENDEE_FIXTURE_B = "attendee_fixture_b" const val PAGE_DETAIL = "page_detail" const val PAGE_WAIT_SALE = "page_wait_sale" const val PAGE_BUY_READY = "page_buy_ready" @@ -27,6 +40,12 @@ object FakeTicketTestTags { const val PAGE_SOLD_OUT = "page_sold_out" const val PAGE_NETWORK_ERROR = "page_network_error" const val PAGE_LOGIN_REQUIRED = "page_login_required" + const val PAGE_QUEUE = "page_queue" + const val PAGE_QUEUE_FAILED = "page_queue_failed" + const val PAGE_BLOCKING_DIALOG = "page_blocking_dialog" + const val PAGE_RISK_CONTROL = "page_risk_control" + const val PAGE_CAPTCHA = "page_captcha" + const val PAGE_UNKNOWN = "page_unknown" const val ACTION_OPEN_SEARCH = "action_open_search" const val INPUT_SEARCH = "input_search" @@ -54,7 +73,14 @@ object FakeTicketTestTags { const val ACTION_DISMISS_DIALOG = "action_dismiss_dialog" const val SCENARIO_CONTROL = "scenario_control" const val ACTION_RESET_SCENARIO = "action_reset_scenario" + const val ACTION_ADVANCE_SALE = "action_advance_sale" + const val ACTION_RESOLVE_QUEUE = "action_resolve_queue" + const val TEXT_QUEUE_STATUS = "text_queue_status" const val ACTION_BACK = "action_back" + + fun session(sessionId: String): String = sessionId.replace('-', '_') + fun tier(tierId: String): String = tierId.replace('-', '_') + fun attendee(profileId: String): String = profileId.replace('-', '_') } data class BlockingDialogState( diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/discovery/DiscoveryScreens.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/discovery/DiscoveryScreens.kt index 763a51e..5a3025a 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/discovery/DiscoveryScreens.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/discovery/DiscoveryScreens.kt @@ -1,6 +1,7 @@ package com.yanbo.faketicket.ui.discovery import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -20,9 +22,11 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -43,6 +47,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.yanbo.faketicket.ui.components.FakeTicketTestTags +import com.yanbo.faketicket.domain.reservation.ReservationBlockReason +import com.yanbo.faketicket.domain.reservation.ReservationFixture +import com.yanbo.faketicket.ui.reservation.ReservationUiEvent +import com.yanbo.faketicket.ui.reservation.ReservationUiState private val PageBackground = Color(0xFFF6F7F9) private val SurfaceColor = Color(0xFFFFFFFF) @@ -496,7 +504,11 @@ private fun DetailLine( } @Composable -fun ReservationSelectDiscoveryScreen() { +fun ReservationSelectDiscoveryScreen( + state: ReservationUiState, + onEvent: (ReservationUiEvent) -> Unit, + onConfirm: () -> Unit, +) { Column( modifier = Modifier .fillMaxSize() @@ -530,7 +542,94 @@ fun ReservationSelectDiscoveryScreen() { fontSize = 13.sp, ) } - Spacer(Modifier.weight(1f)) + LazyColumn( + modifier = Modifier + .weight(1f) + .testTag(FakeTicketTestTags.LIST_RESERVATION_OPTIONS), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 14.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + SectionLabel("场次") + } + items(ReservationFixture.sessions, key = { it.id }) { session -> + SelectionRow( + selected = state.selection.sessionId == session.id, + label = session.displayText, + tag = FakeTicketTestTags.session(session.id), + onClick = { onEvent(ReservationUiEvent.SelectSession(session.id)) }, + ) + } + item { + SectionLabel("票档") + } + items(ReservationFixture.ticketTiers, key = { it.id }) { tier -> + SelectionRow( + selected = state.selection.tierId == tier.id, + label = "¥${tier.priceYuan}", + tag = FakeTicketTestTags.tier(tier.id), + onClick = { onEvent(ReservationUiEvent.SelectTier(tier.id)) }, + ) + } + item { + SectionLabel("数量") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { onEvent(ReservationUiEvent.DecreaseQuantity) }, + enabled = state.selection.quantity > 1, + modifier = Modifier.testTag(FakeTicketTestTags.QUANTITY_DECREASE), + ) { + Text("−", fontSize = 22.sp) + } + Text( + text = state.selection.quantity.toString(), + modifier = Modifier.testTag(FakeTicketTestTags.QUANTITY_VALUE), + color = PrimaryText, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + TextButton( + onClick = { onEvent(ReservationUiEvent.IncreaseQuantity) }, + enabled = state.selection.quantity < 2, + modifier = Modifier.testTag(FakeTicketTestTags.QUANTITY_INCREASE), + ) { + Text("+", fontSize = 22.sp) + } + } + } + item { + SectionLabel("实名观演人") + } + items(ReservationFixture.attendeeProfiles, key = { it.id }) { profile -> + val selected = profile.id in state.selection.attendeeProfileIds + Row( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceColor, RoundedCornerShape(6.dp)) + .clickable { onEvent(ReservationUiEvent.ToggleAttendee(profile.id)) } + .padding(horizontal = 12.dp, vertical = 10.dp) + .testTag(FakeTicketTestTags.attendee(profile.id)), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = selected, + onCheckedChange = { + onEvent(ReservationUiEvent.ToggleAttendee(profile.id)) + }, + ) + Text("观演人 ${profile.displayName}", color = PrimaryText, fontSize = 15.sp) + } + } + } Box( modifier = Modifier .fillMaxWidth() @@ -538,16 +637,65 @@ fun ReservationSelectDiscoveryScreen() { .padding(horizontal = 16.dp, vertical = 12.dp), ) { Button( - onClick = {}, - enabled = false, + onClick = { + onEvent(ReservationUiEvent.ConfirmReservation) + onConfirm() + }, + enabled = state.canConfirm, modifier = Modifier .fillMaxWidth() .height(48.dp) .testTag(FakeTicketTestTags.ACTION_CONFIRM_RESERVATION), shape = RoundedCornerShape(6.dp), ) { - Text("确定", letterSpacing = 0.sp) + Text(confirmButtonText(state), letterSpacing = 0.sp) } } } } + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + color = PrimaryText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun SelectionRow( + selected: Boolean, + label: String, + tag: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceColor, RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp) + .testTag(tag), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, color = PrimaryText, fontSize = 15.sp) + } +} + +private fun confirmButtonText(state: ReservationUiState): String = when (state.blockReason) { + ReservationBlockReason.SESSION_REQUIRED, + ReservationBlockReason.SESSION_INVALID, + -> "请选择场次" + ReservationBlockReason.TIER_REQUIRED, + ReservationBlockReason.TIER_INVALID, + -> "请选择票档" + ReservationBlockReason.ATTENDEE_REQUIRED, + ReservationBlockReason.ATTENDEE_AMBIGUOUS, + -> "请选择 ${state.selection.quantity} 位观演人" + ReservationBlockReason.TOTAL_LIMIT_EXCEEDED -> "总额超过 ¥${ReservationFixture.maximumTotalYuan}" + ReservationBlockReason.QUANTITY_OUT_OF_RANGE -> "数量不可用" + null -> "确定 · ¥${state.totalPriceYuan}" +} diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModel.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModel.kt new file mode 100644 index 0000000..5975bd0 --- /dev/null +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModel.kt @@ -0,0 +1,92 @@ +package com.yanbo.faketicket.ui.reservation + +import androidx.lifecycle.ViewModel +import com.yanbo.faketicket.domain.reservation.ReservationBlockReason +import com.yanbo.faketicket.domain.reservation.ReservationEvaluation +import com.yanbo.faketicket.domain.reservation.ReservationFixture +import com.yanbo.faketicket.domain.reservation.ReservationOrderDraft +import com.yanbo.faketicket.domain.reservation.ReservationOrderPolicy +import com.yanbo.faketicket.domain.reservation.ReservationSelection +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ReservationUiState( + val selection: ReservationSelection = ReservationSelection(), + val canConfirm: Boolean = false, + val blockReason: ReservationBlockReason? = ReservationBlockReason.SESSION_REQUIRED, + val totalPriceYuan: Int? = null, + val orderDraft: ReservationOrderDraft? = null, +) + +sealed interface ReservationUiEvent { + data class SelectSession(val sessionId: String) : ReservationUiEvent + data class SelectTier(val tierId: String) : ReservationUiEvent + data object DecreaseQuantity : ReservationUiEvent + data object IncreaseQuantity : ReservationUiEvent + data class ToggleAttendee(val attendeeProfileId: String) : ReservationUiEvent + data object ConfirmReservation : ReservationUiEvent +} + +class ReservationFlowViewModel( + private val policy: ReservationOrderPolicy = ReservationOrderPolicy(), +) : ViewModel() { + private val _uiState = MutableStateFlow(ReservationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEvent(event: ReservationUiEvent) { + when (event) { + is ReservationUiEvent.SelectSession -> updateSelection { + copy(sessionId = event.sessionId) + } + is ReservationUiEvent.SelectTier -> updateSelection { + copy(tierId = event.tierId) + } + ReservationUiEvent.DecreaseQuantity -> updateSelection { + copy( + quantity = (quantity - 1).coerceAtLeast(1), + attendeeProfileIds = attendeeProfileIds.take((quantity - 1).coerceAtLeast(1)), + ) + } + ReservationUiEvent.IncreaseQuantity -> updateSelection { + copy(quantity = (quantity + 1).coerceAtMost(2)) + } + is ReservationUiEvent.ToggleAttendee -> updateSelection { + val toggled = if (event.attendeeProfileId in attendeeProfileIds) { + attendeeProfileIds - event.attendeeProfileId + } else { + attendeeProfileIds + event.attendeeProfileId + } + copy(attendeeProfileIds = toggled.filter { profileId -> + ReservationFixture.attendeeProfiles.any { it.id == profileId } + }) + } + ReservationUiEvent.ConfirmReservation -> confirmReservation() + } + } + + private fun updateSelection(transform: ReservationSelection.() -> ReservationSelection) { + _uiState.update { current -> + val selection = current.selection.transform() + current.withEvaluation(selection, policy.evaluate(selection)).copy(orderDraft = null) + } + } + + private fun confirmReservation() { + _uiState.update { current -> + val draft = policy.createDraft(current.selection) + current.copy(orderDraft = draft) + } + } + + private fun ReservationUiState.withEvaluation( + selection: ReservationSelection, + evaluation: ReservationEvaluation, + ) = copy( + selection = selection, + canConfirm = evaluation.canConfirm, + blockReason = evaluation.reason, + totalPriceYuan = evaluation.totalPriceYuan, + ) +} diff --git a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/screens/Screens.kt b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/screens/Screens.kt index 24a13b6..4de5ac5 100644 --- a/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/screens/Screens.kt +++ b/fake-ticket-app/src/main/java/com/yanbo/faketicket/ui/screens/Screens.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -12,9 +13,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.yanbo.faketicket.ui.components.FakeTicketTestTags +import com.yanbo.faketicket.domain.reservation.ReservationOrderDraft +import com.yanbo.faketicket.scenario.QueueState @Composable -fun WaitSaleScreen(onBuyReady: () -> Unit, onBack: () -> Unit) { +fun WaitSaleScreen(onAdvanceSale: () -> Unit, onReset: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -25,19 +28,17 @@ fun WaitSaleScreen(onBuyReady: () -> Unit, onBack: () -> Unit) { ) { Text("等待开售") Button( - onClick = onBuyReady, - modifier = Modifier.testTag(FakeTicketTestTags.ACTION_BUY) + onClick = onAdvanceSale, + modifier = Modifier.testTag(FakeTicketTestTags.ACTION_ADVANCE_SALE) ) { - Text("已开售") - } - Button(onClick = onBack) { - Text("返回") + Text("推进到开售时间") } + ResetScenarioButton(onReset) } } @Composable -fun BuyReadyScreen(onBuy: () -> Unit, onBack: () -> Unit) { +fun BuyReadyScreen(onBuy: () -> Unit, onReset: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -53,9 +54,7 @@ fun BuyReadyScreen(onBuy: () -> Unit, onBack: () -> Unit) { ) { Text("立即购买") } - Button(onClick = onBack) { - Text("返回") - } + ResetScenarioButton(onReset) } } @@ -83,7 +82,11 @@ fun TicketSelectScreen(onConfirm: () -> Unit, onBack: () -> Unit) { } @Composable -fun OrderConfirmScreen(onSubmit: () -> Unit, onBack: () -> Unit) { +fun OrderConfirmScreen( + draft: ReservationOrderDraft?, + onSubmit: () -> Unit, + onBack: () -> Unit, +) { Column( modifier = Modifier .fillMaxSize() @@ -93,8 +96,23 @@ fun OrderConfirmScreen(onSubmit: () -> Unit, onBack: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally ) { Text("订单确认") + if (draft == null) { + Text("订单信息不完整") + } else { + Text(draft.performanceTitle) + Text(draft.session.displayText) + Text("票档 ¥${draft.tier.priceYuan} × ${draft.quantity}") + Text("票款 ¥${draft.ticketSubtotalYuan}") + Text("服务费 ¥${draft.serviceFeeYuan}") + Text("观演人 ${draft.attendeeProfiles.joinToString { it.displayName }}") + Text( + "合计 ¥${draft.totalPriceYuan}", + modifier = Modifier.testTag(FakeTicketTestTags.TEXT_ORDER_SUMMARY), + ) + } Button( onClick = onSubmit, + enabled = draft != null, modifier = Modifier.testTag(FakeTicketTestTags.ACTION_SUBMIT_ORDER) ) { Text("提交订单") @@ -106,7 +124,7 @@ fun OrderConfirmScreen(onSubmit: () -> Unit, onBack: () -> Unit) { } @Composable -fun PaymentScreen(onPay: () -> Unit, onBack: () -> Unit) { +fun PaymentScreen(draft: ReservationOrderDraft?, onBack: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -115,19 +133,14 @@ fun PaymentScreen(onPay: () -> Unit, onBack: () -> Unit) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text("模拟支付") + Text("订单已提交") Text( - "仅模拟、不产生真实扣款", + "支付请由用户本人处理", modifier = Modifier.testTag(FakeTicketTestTags.TEXT_PAYMENT_DISCLAIMER) ) - Button( - onClick = onPay, - modifier = Modifier.testTag(FakeTicketTestTags.ACTION_PAY) - ) { - Text("确认支付") - } + draft?.let { Text("待支付 ¥${it.totalPriceYuan}") } Button(onClick = onBack) { - Text("返回") + Text("返回复核") } } } @@ -157,55 +170,147 @@ fun SuccessScreen(onBackHome: () -> Unit) { } @Composable -fun SoldOutScreen() { +fun SoldOutScreen(onReset: () -> Unit) { + StopFixtureScreen( + pageTag = FakeTicketTestTags.PAGE_SOLD_OUT, + title = "已售罄", + detail = "当前票档库存为 0", + onReset = onReset, + ) +} + +@Composable +fun NetworkErrorScreen(onReset: () -> Unit) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp) - .testTag(FakeTicketTestTags.PAGE_SOLD_OUT), + .testTag(FakeTicketTestTags.PAGE_NETWORK_ERROR), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text("已售罄") + Text("网络异常") + Text("使用确定性本地 fixture,不连接网络") + ResetScenarioButton(onReset) } } @Composable -fun NetworkErrorScreen() { +fun LoginRequiredScreen(onReset: () -> Unit) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp) - .testTag(FakeTicketTestTags.PAGE_NETWORK_ERROR), + .testTag(FakeTicketTestTags.PAGE_LOGIN_REQUIRED), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text("网络异常") - Button( - onClick = { }, - modifier = Modifier.testTag(FakeTicketTestTags.ACTION_RETRY_NETWORK) - ) { - Text("重试") - } + Text("登录已失效") + Text("停止操作,由用户本人重新登录") + ResetScenarioButton(onReset) } } @Composable -fun LoginRequiredScreen() { +fun QueueFixtureScreen( + queueState: QueueState, + onResolve: () -> Unit, + onReset: () -> Unit, +) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp) - .testTag(FakeTicketTestTags.PAGE_LOGIN_REQUIRED), + .testTag( + if (queueState == QueueState.FAILURE || queueState == QueueState.TIMEOUT) { + FakeTicketTestTags.PAGE_QUEUE_FAILED + } else { + FakeTicketTestTags.PAGE_QUEUE + }, + ), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("需要登录") - Button( - onClick = { }, - modifier = Modifier.testTag(FakeTicketTestTags.ACTION_RELOGIN) - ) { - Text("重新登录") + Text( + when (queueState) { + QueueState.IDLE -> "尚未排队" + QueueState.WAITING -> "排队中" + QueueState.SUCCESS -> "排队成功" + QueueState.FAILURE -> "排队失败" + QueueState.TIMEOUT -> "排队超时" + }, + modifier = Modifier.testTag(FakeTicketTestTags.TEXT_QUEUE_STATUS), + ) + if (queueState == QueueState.WAITING) { + Button( + onClick = onResolve, + modifier = Modifier.testTag(FakeTicketTestTags.ACTION_RESOLVE_QUEUE), + ) { + Text("推进确定结果") + } } + ResetScenarioButton(onReset) + } +} + +@Composable +fun BlockingDialogFixtureScreen( + dialogVisible: Boolean, + message: String, + onDismiss: () -> Unit, + onReset: () -> Unit, +) { + StopFixtureScreen( + pageTag = FakeTicketTestTags.PAGE_BLOCKING_DIALOG, + title = "阻断弹窗场景", + detail = "弹窗关闭后仍保持当前 fixture", + onReset = onReset, + ) + if (dialogVisible) { + AlertDialog( + modifier = Modifier.testTag(FakeTicketTestTags.DIALOG_BLOCKING), + onDismissRequest = { }, + title = { Text(message) }, + text = { Text("停止操作,等待用户处理") }, + confirmButton = { + Button( + onClick = onDismiss, + modifier = Modifier.testTag(FakeTicketTestTags.ACTION_DISMISS_DIALOG), + ) { + Text("关闭") + } + }, + ) + } +} + +@Composable +fun StopFixtureScreen( + pageTag: String, + title: String, + detail: String, + onReset: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .testTag(pageTag), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title) + Text(detail) + ResetScenarioButton(onReset) + } +} + +@Composable +private fun ResetScenarioButton(onReset: () -> Unit) { + Button( + onClick = onReset, + modifier = Modifier.testTag(FakeTicketTestTags.ACTION_RESET_SCENARIO), + ) { + Text("重置场景") } } diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/FakeTicketPageContractTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/FakeTicketPageContractTest.kt index 212b926..51b681a 100644 --- a/fake-ticket-app/src/test/java/com/yanbo/faketicket/FakeTicketPageContractTest.kt +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/FakeTicketPageContractTest.kt @@ -8,8 +8,8 @@ import org.junit.Test class FakeTicketPageContractTest { @Test - fun `FakeTicketPage has exactly 13 values`() { - assertEquals(13, FakeTicketPage.entries.size) + fun `FakeTicketPage has exactly 18 values`() { + assertEquals(18, FakeTicketPage.entries.size) } @Test @@ -102,6 +102,20 @@ class FakeTicketPageContractTest { assertEquals("page_login_required", FakeTicketPage.LOGIN_REQUIRED.tag) } + @Test + fun `stage 04 fixture pages have stable routes and tags`() { + assertEquals("queue", FakeTicketPage.QUEUE.route) + assertEquals("page_queue", FakeTicketPage.QUEUE.tag) + assertEquals("blocking_dialog", FakeTicketPage.BLOCKING_DIALOG.route) + assertEquals("page_blocking_dialog", FakeTicketPage.BLOCKING_DIALOG.tag) + assertEquals("risk_control", FakeTicketPage.RISK_CONTROL.route) + assertEquals("page_risk_control", FakeTicketPage.RISK_CONTROL.tag) + assertEquals("captcha", FakeTicketPage.CAPTCHA.route) + assertEquals("page_captcha", FakeTicketPage.CAPTCHA.tag) + assertEquals("unknown", FakeTicketPage.UNKNOWN.route) + assertEquals("page_unknown", FakeTicketPage.UNKNOWN.tag) + } + @Test fun `FakeTicketRoute constants match page routes`() { assertEquals(FakeTicketPage.HOME.route, FakeTicketRoute.HOME) @@ -117,6 +131,11 @@ class FakeTicketPageContractTest { assertEquals(FakeTicketPage.SOLD_OUT.route, FakeTicketRoute.SOLD_OUT) assertEquals(FakeTicketPage.NETWORK_ERROR.route, FakeTicketRoute.NETWORK_ERROR) assertEquals(FakeTicketPage.LOGIN_REQUIRED.route, FakeTicketRoute.LOGIN_REQUIRED) + assertEquals(FakeTicketPage.QUEUE.route, FakeTicketRoute.QUEUE) + assertEquals(FakeTicketPage.BLOCKING_DIALOG.route, FakeTicketRoute.BLOCKING_DIALOG) + assertEquals(FakeTicketPage.RISK_CONTROL.route, FakeTicketRoute.RISK_CONTROL) + assertEquals(FakeTicketPage.CAPTCHA.route, FakeTicketRoute.CAPTCHA) + assertEquals(FakeTicketPage.UNKNOWN.route, FakeTicketRoute.UNKNOWN) } @Test diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/data/InventoryStoreTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/data/InventoryStoreTest.kt index 99faed2..0f35f39 100644 --- a/fake-ticket-app/src/test/java/com/yanbo/faketicket/data/InventoryStoreTest.kt +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/data/InventoryStoreTest.kt @@ -10,10 +10,13 @@ import org.junit.Test class InventoryStoreTest { @Test - fun `initial stock is 2 for both tiers`() = runBlocking { + fun `initial stock covers all seven reservation tiers`() = runBlocking { val store = InventoryStore() - assertEquals(2, store.getStock("tier-580")) - assertEquals(2, store.getStock("tier-880")) + assertEquals( + setOf("tier-580", "tier-780", "tier-980", "tier-1280", "tier-1680", "tier-1980", "tier-2380"), + store.getAll().keys, + ) + assertTrue(store.getAll().values.all { it == 2 }) } @Test @@ -47,7 +50,7 @@ class InventoryStoreTest { store.tryDeduct("tier-580", 2) store.reset() assertEquals(2, store.getStock("tier-580")) - assertEquals(2, store.getStock("tier-880")) + assertTrue(store.getAll().values.all { it == 2 }) } @Test diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicyTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicyTest.kt new file mode 100644 index 0000000..3399ce6 --- /dev/null +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/domain/reservation/ReservationOrderPolicyTest.kt @@ -0,0 +1,139 @@ +package com.yanbo.faketicket.domain.reservation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReservationOrderPolicyTest { + private val policy = ReservationOrderPolicy() + + @Test + fun `fixture exposes three exact sessions and matches date plus time uniquely`() { + assertEquals( + listOf( + "2026-12-18 19:30", + "2026-12-19 19:30", + "2026-12-20 19:30", + ), + ReservationFixture.sessions.map { "${it.dateIso} ${it.time24Hour}" }, + ) + + assertEquals( + "session-2026-12-19-1930", + policy.matchSession(dateIso = "2026-12-19", time24Hour = "19:30")?.id, + ) + assertNull(policy.matchSession(dateIso = "2026-12-19", time24Hour = "20:00")) + } + + @Test + fun `fixture exposes seven tiers in stable low to high price order`() { + assertEquals( + listOf(580, 780, 980, 1280, 1680, 1980, 2380), + ReservationFixture.ticketTiers.map { it.priceYuan }, + ) + } + + @Test + fun `preferred tier falls back in configured order`() { + val preference = TicketTierPreference( + preferredTierId = "tier-1680", + backupTierIds = listOf("tier-1280", "tier-980"), + ) + + assertEquals( + "tier-1280", + policy.resolveTier( + preference = preference, + availableTierIds = setOf("tier-580", "tier-980", "tier-1280"), + )?.id, + ) + assertNull(policy.resolveTier(preference, availableTierIds = setOf("tier-580"))) + } + + @Test + fun `confirmation stays disabled until session tier and one attendee are selected`() { + assertFalse(policy.evaluate(ReservationSelection()).canConfirm) + assertEquals( + ReservationBlockReason.SESSION_REQUIRED, + policy.evaluate(ReservationSelection()).reason, + ) + + val complete = ReservationSelection( + sessionId = "session-2026-12-18-1930", + tierId = "tier-980", + quantity = 1, + attendeeProfileIds = listOf("attendee-fixture-a"), + ) + assertTrue(policy.evaluate(complete).canConfirm) + } + + @Test + fun `order total includes explicit service fee`() { + val selection = ReservationSelection( + sessionId = "session-2026-12-18-1930", + tierId = "tier-980", + quantity = 2, + attendeeProfileIds = listOf("attendee-fixture-a", "attendee-fixture-b"), + ) + + val draft = policy.createDraft(selection)!! + + assertEquals(1960, draft.ticketSubtotalYuan) + assertEquals(20, draft.serviceFeeYuan) + assertEquals(1980, draft.totalPriceYuan) + } + + @Test + fun `quantity outside one to two is rejected`() { + val selection = completeSelection(quantity = 3) + + assertEquals( + ReservationBlockReason.QUANTITY_OUT_OF_RANGE, + policy.evaluate(selection).reason, + ) + } + + @Test + fun `total above fixture limit is rejected`() { + val selection = completeSelection( + tierId = "tier-2380", + quantity = 2, + attendeeProfileIds = listOf("attendee-fixture-a", "attendee-fixture-b"), + ) + + val result = policy.evaluate(selection) + + assertEquals(4780, result.totalPriceYuan) + assertEquals(ReservationBlockReason.TOTAL_LIMIT_EXCEEDED, result.reason) + assertFalse(result.canConfirm) + } + + @Test + fun `missing or ambiguous attendee selection is rejected`() { + assertEquals( + ReservationBlockReason.ATTENDEE_REQUIRED, + policy.evaluate(completeSelection(attendeeProfileIds = emptyList())).reason, + ) + assertEquals( + ReservationBlockReason.ATTENDEE_AMBIGUOUS, + policy.evaluate( + completeSelection( + attendeeProfileIds = listOf("attendee-fixture-a", "attendee-fixture-b"), + ), + ).reason, + ) + } + + private fun completeSelection( + tierId: String = "tier-980", + quantity: Int = 1, + attendeeProfileIds: List = listOf("attendee-fixture-a"), + ) = ReservationSelection( + sessionId = "session-2026-12-18-1930", + tierId = tierId, + quantity = quantity, + attendeeProfileIds = attendeeProfileIds, + ) +} diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/QueueScenarioControllerTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/QueueScenarioControllerTest.kt new file mode 100644 index 0000000..c9a625b --- /dev/null +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/QueueScenarioControllerTest.kt @@ -0,0 +1,70 @@ +package com.yanbo.faketicket.scenario + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class QueueScenarioControllerTest { + @Test + fun `queue result transitions deterministically through waiting`() { + QueueFixtureResult.entries.forEach { result -> + val controller = ScenarioController() + + controller.startQueue(result) + assertEquals(QueueState.WAITING, controller.scenarioState.value.queueState) + assertEquals(result, controller.scenarioState.value.pendingQueueResult) + + controller.resolveQueue() + assertEquals(result.finalState, controller.scenarioState.value.queueState) + } + } + + @Test + fun `all stop fixtures can be selected without network time or randomness`() { + val fixtures = listOf( + FakeTicketScenario.SOLD_OUT, + FakeTicketScenario.NETWORK_ERROR, + FakeTicketScenario.BLOCKING_DIALOG, + FakeTicketScenario.LOGIN_REQUIRED, + FakeTicketScenario.RISK_CONTROL, + FakeTicketScenario.CAPTCHA, + FakeTicketScenario.UNKNOWN_PAGE, + ) + + fixtures.forEach { fixture -> + val controller = ScenarioController() + controller.setScenario(fixture) + assertEquals(fixture, controller.scenarioState.value.scenario) + } + } + + @Test + fun `reset clears queue dialog sale clock and inventory`() { + val controller = ScenarioController() + controller.advanceClockToSaleTime() + controller.startQueue(QueueFixtureResult.TIMEOUT) + controller.showDialog("fixture") + controller.resolveQueue() + controller.reset() + + val state = controller.scenarioState.value + assertEquals(FakeTicketScenario.NORMAL, state.scenario) + assertEquals(QueueState.IDLE, state.queueState) + assertEquals(null, state.pendingQueueResult) + assertFalse(state.dialogVisible) + assertEquals(com.yanbo.faketicket.domain.sale.SaleState.WAIT_SALE, controller.getCurrentSaleState()) + assertEquals(2, kotlinx.coroutines.runBlocking { controller.getInventoryStore().getStock("tier-580") }) + } + + @Test + fun `sold out fixture clears every tier and reset restores inventory`() = kotlinx.coroutines.runBlocking { + val controller = ScenarioController() + + controller.setScenario(FakeTicketScenario.SOLD_OUT) + assertEquals(com.yanbo.faketicket.domain.sale.SaleState.SOLD_OUT, controller.getCurrentSaleState()) + assertEquals(setOf(0), controller.getInventoryStore().getAll().values.toSet()) + + controller.reset() + assertEquals(setOf(2), controller.getInventoryStore().getAll().values.toSet()) + } +} diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioBuildPolicyTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioBuildPolicyTest.kt new file mode 100644 index 0000000..d67d96c --- /dev/null +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioBuildPolicyTest.kt @@ -0,0 +1,19 @@ +package com.yanbo.faketicket.scenario + +import com.yanbo.faketicket.BuildConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScenarioBuildPolicyTest { + @Test + fun `scenario injection availability follows build type`() { + assertEquals(BuildConfig.DEBUG, ScenarioBuildPolicy.allowInjection) + val parsed = ScenarioInjectionPolicy(ScenarioBuildPolicy.allowInjection).parse("CAPTCHA") + if (BuildConfig.DEBUG) { + assertEquals(FakeTicketScenario.CAPTCHA, parsed) + } else { + assertNull(parsed) + } + } +} diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioInjectionPolicyTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioInjectionPolicyTest.kt new file mode 100644 index 0000000..12a4d17 --- /dev/null +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/scenario/ScenarioInjectionPolicyTest.kt @@ -0,0 +1,29 @@ +package com.yanbo.faketicket.scenario + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScenarioInjectionPolicyTest { + @Test + fun `debug accepts known scenario case insensitively`() { + val policy = ScenarioInjectionPolicy(allowInjection = true) + + assertEquals(FakeTicketScenario.CAPTCHA, policy.parse("captcha")) + assertEquals(FakeTicketScenario.QUEUE_SUCCESS, policy.parse("QUEUE_SUCCESS")) + } + + @Test + fun `unknown scenario is rejected`() { + assertNull(ScenarioInjectionPolicy(allowInjection = true).parse("not-a-fixture")) + } + + @Test + fun `release rejects every scenario including known values`() { + val policy = ScenarioInjectionPolicy(allowInjection = false) + + FakeTicketScenario.entries.forEach { scenario -> + assertNull(policy.parse(scenario.name)) + } + } +} diff --git a/fake-ticket-app/src/test/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModelTest.kt b/fake-ticket-app/src/test/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModelTest.kt new file mode 100644 index 0000000..a34ecf4 --- /dev/null +++ b/fake-ticket-app/src/test/java/com/yanbo/faketicket/ui/reservation/ReservationFlowViewModelTest.kt @@ -0,0 +1,80 @@ +package com.yanbo.faketicket.ui.reservation + +import com.yanbo.faketicket.domain.reservation.ReservationBlockReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReservationFlowViewModelTest { + @Test + fun `initial confirmation is disabled because session is missing`() { + val viewModel = ReservationFlowViewModel() + + assertFalse(viewModel.uiState.value.canConfirm) + assertEquals(ReservationBlockReason.SESSION_REQUIRED, viewModel.uiState.value.blockReason) + assertNull(viewModel.uiState.value.orderDraft) + } + + @Test + fun `complete exact selection creates order draft`() { + val viewModel = ReservationFlowViewModel() + + viewModel.onEvent(ReservationUiEvent.SelectSession("session-2026-12-19-1930")) + viewModel.onEvent(ReservationUiEvent.SelectTier("tier-1280")) + viewModel.onEvent(ReservationUiEvent.ToggleAttendee("attendee-fixture-a")) + + assertTrue(viewModel.uiState.value.canConfirm) + viewModel.onEvent(ReservationUiEvent.ConfirmReservation) + + val draft = viewModel.uiState.value.orderDraft + assertNotNull(draft) + assertEquals("12月19日 周六 19:30", draft?.session?.displayText) + assertEquals(1280, draft?.ticketSubtotalYuan) + assertEquals(10, draft?.serviceFeeYuan) + assertEquals(1290, draft?.totalPriceYuan) + } + + @Test + fun `two tickets require two distinct attendees`() { + val viewModel = ReservationFlowViewModel() + viewModel.onEvent(ReservationUiEvent.SelectSession("session-2026-12-18-1930")) + viewModel.onEvent(ReservationUiEvent.SelectTier("tier-980")) + viewModel.onEvent(ReservationUiEvent.IncreaseQuantity) + viewModel.onEvent(ReservationUiEvent.ToggleAttendee("attendee-fixture-a")) + + assertFalse(viewModel.uiState.value.canConfirm) + assertEquals(ReservationBlockReason.ATTENDEE_AMBIGUOUS, viewModel.uiState.value.blockReason) + + viewModel.onEvent(ReservationUiEvent.ToggleAttendee("attendee-fixture-b")) + assertTrue(viewModel.uiState.value.canConfirm) + } + + @Test + fun `over-limit total never creates draft`() { + val viewModel = ReservationFlowViewModel() + viewModel.onEvent(ReservationUiEvent.SelectSession("session-2026-12-20-1930")) + viewModel.onEvent(ReservationUiEvent.SelectTier("tier-2380")) + viewModel.onEvent(ReservationUiEvent.IncreaseQuantity) + viewModel.onEvent(ReservationUiEvent.ToggleAttendee("attendee-fixture-a")) + viewModel.onEvent(ReservationUiEvent.ToggleAttendee("attendee-fixture-b")) + + assertEquals(4780, viewModel.uiState.value.totalPriceYuan) + assertEquals(ReservationBlockReason.TOTAL_LIMIT_EXCEEDED, viewModel.uiState.value.blockReason) + viewModel.onEvent(ReservationUiEvent.ConfirmReservation) + assertNull(viewModel.uiState.value.orderDraft) + } + + @Test + fun `quantity stays inside one to two`() { + val viewModel = ReservationFlowViewModel() + + viewModel.onEvent(ReservationUiEvent.DecreaseQuantity) + assertEquals(1, viewModel.uiState.value.selection.quantity) + + repeat(3) { viewModel.onEvent(ReservationUiEvent.IncreaseQuantity) } + assertEquals(2, viewModel.uiState.value.selection.quantity) + } +}