Skip to content

Testing

lingion edited this page Sep 16, 2026 · 3 revisions

Testing

一句话 TL;DR:这套测试体系用 JUnit 把 340 所学校的解析契约、几何引擎、撤回快照、组件 manifest 接线和导入往返锁成可执行的回归网。本页给想加新学校、加新组件变体、改动几何/撤回的贡献者看。

本文基于 v1.0.54。

仓库的测试代码全部在 app/src/test/ 下,跑 ./gradlew :app:testDebugUnitTest 即可,不依赖设备。

测试规模与构成(截至 v1.0.54)

测试类 166 个,用例 1499 个(@Test 数累加)。包结构按职责拆:

包 测试类数 锁的对象
data/jw 43 教务解析器(Jw*Parser)+ 协议识别 + 340 校回归 + schools.json 一致性
data/parser 19 文本/ICS/JSON/WakeUp 分享 + sleepy-v1 原生格式的导入导出 + 无损往返
util 22 几何引擎(ConflictLayoutEngine、ClusterOwnTimeGeometry)、TimeTableUtils、HolidayManager、DateUtils
widget 23 10 个 widget 变体的 manifest/接线/路由/渲染契约,跨厂商 pin 路由
ui/component 2 冲突卡片几何、字体缩放
ui/screen 16 编辑/导入/课表页的结构契约,WebView JS 与 Kotlin 双侧协议一致
ui/theme 2 自定义主题派生、UI 契约
data/undo 2 UndoManager 单槽语义、撤回快照覆盖率
data/entity 3 实体、smartPeriod 配置、ICS 节次拆分
data/diff 1 冲突行差分
data 根 3 Room 迁移、实体标志、自定义主题核心
仓库根 6 StringsKeyParityTest、ManifestExternalOpenFilterTest、AppPrefsIsolationTest、BackRestoreSaveableContractTest、AboutLicenseAttributionTest、CourseColorUtilTest

测试运行走 app/build.gradle.kts:62 的 testOptions.unitTests,isIncludeAndroidResources = true + isReturnDefaultValues = true(app/build.gradle.kts:64-65)。Robolectric 全程未引入(grep 全测试树零命中),returnDefaultValues 模式让 Bitmap.createBitmap 之类返回 null 桩,纯 JVM 跑得动 widget 相关逻辑;代价是任何依赖真实位图/Context 的行为没法直接断言,改 widget 渲染时要么补源扫描契约测试,要么走真机验证。instrumented test 目录(app/src/androidTest/)不存在,依赖声明里的 androidTestImplementation 是默认模板遗留。

测试哲学:行为契约靠三类测试锁

Room 实例、Compose 树、Glance 渲染都没法在纯 JVM 里搭起来。仓库的选择是不引 Robolectric,把「无法运行时构造的东西」改成「可静态读取的东西」来断言。三类策略撑住整个回归网:

第一类:源码级扫描测试。声明式数据和接口接线读源文件等价于读打包产物。一组测试直接 File("app/src/main/...").readText() 后用正则或字符串包含断言:

  • UndoCaptureCoverageTest(data/undo/UndoCaptureCoverageTest.kt:18)扫 ScheduleRepository.kt,断言 14 个公开写方法体里都出现 captureForUndo()(:45),并单独钉死 applyDiff 必须在第一次 DAO 写之前 capture(:54)。缺一个 capture,对应动作的撤回按钮就消失一次——测试注释里写明了这个后果。
  • WidgetInfoXmlContractTest(widget/WidgetInfoXmlContractTest.kt:21)用 DOM 解析 AndroidManifest.xml + res/xml/*_widget_info.xml,断言 10 个 receiver ↔ 10 个 info XML 一一对应(:76、:124)、任何 info XML 不得声明 android:configure(:98)、全部必须声明 reconfigurable widgetFeatures(:112)。这条契约阻止「添加桌面组件时弹配置白屏」。
  • StringsKeyParityTest(根目录,StringsKeyParityTest.kt:24)扫描 6 个 locale 目录的 strings.xml,断言新加的 string key 在所有语言下都齐(:103)。缺任一即 MissingTranslation lint 回归。
  • ManifestExternalOpenFilterTest(根目录)读 AndroidManifest.xml 断言 intent-filter 注册面覆盖 .html/.ics/.csv 等 MIME,文件管理器点文件才能把内容送进导入入口。
  • AppPrefsIsolationTest(根目录)在字符串字面量级别断言两个 colorless key 互不相同、getter/setter 不互读。
  • ScheduleViewModeSessionContractTest(ui/screen/schedule/ScheduleViewModeSessionContractTest.kt:28)扫 ScheduleScreen.kt + MainActivity.kt,钉死 viewMode 状态提升与会话层真相源,防「网格视图被弹回周视图」的回潮。

这类测试共 30 个(grep src/main 数到),覆盖 manifest、res xml、Kotlin 源、assets 四类声明式数据。实现套路一致:File("app/src/main/...") 与 File("src/main/...") 双路径试探兼容两种工作目录,读全文,再 Regex.containsMatchIn 或 assertTrue(String in text) 断言。断言消息里写清违约后果,红的时候贡献者不用翻历史就能知道该补什么。

第二类:协议 fixture 矩阵。每种教务协议都有真实采集的 HTML/JSON 样本和同名 *.expected.json oracle,parser 测试把两者一比对就能定位漂移。样本本身也是契约:JwProtocolFixtureMatrixTest 读 expected 文件里的 _fingerprint 五字段(urlMarkers / htmlMarkers / expectedProtocol / expectedConfidence / expectedParseResult),断言识别层对每个样本返回正确协议族,样本删除会触发下限闸。新协议想进识别层,加一份 html + expected 即被自动纳入。

第三类:行为引擎测试 + 无损往返。冲突布局、几何聚簇、解析往返、DB 迁移这些带真实逻辑但能在纯 JVM 跑的对象,走完整业务语义测试。

三类里的代表性测试类与各自锁的契约:

测试类 锁什么
UndoCaptureCoverageTest 14 个公开写方法每个都先 capture 再写库
WidgetInfoXmlContractTest receiver ↔ info XML 一一对应,不弹配置页,保留 reconfigurable
StringsKeyParityTest 新 string key 在全部 6 个 locale 都存在
ManifestExternalOpenFilterTest 外部打开 intent-filter 覆盖 .html/.ics/.csv 等 MIME
ScheduleViewModeSessionContractTest 课表视图模式状态提升到会话层,overlay 往返不丢
JwQzAppWebViewContractTest qz_app 抓取走专用 JS 分支,协议字段解码只在 Kotlin 侧
JwParserFixtureTest 63 个脱敏样本九字段契约,顺序敏感
JwProtocolFixtureMatrixTest expectedProtocol 指纹与 detectProtocolFromHtml 一致
SchoolsJsonConsistencyTest schools.json 的 type 可声明、可路由、displayName/category 不缺
Schools179CrossValidationTest 条目总数 340,私网地址与死链永不入清单
ConflictLayoutEngineTest 聚簇闭包、变体分配、z 序翻转
ClusterOwnTimeGeometryTest 簇内几何 (startFrac, endFrac) 契约与坐标系一致
PinWidgetRoutingTest widget_type 路由覆盖全部 10 个变体,未知值回落 WeekGrid
CourseAliasMigrationTest Room 迁移 SQL 单一事实来源,版本链严格连续

第二类四个核心类的细节:

  • JwParserFixtureTest(data/jw/JwParserFixtureTest.kt:18)63 个手写脱敏样本走九字段契约(类头注释 :11),每个样本的 name/day/startNode/endNode/startWeek/endWeek/type/teacher/room 与 *.expected.json 逐字对比(:110)。case 表注册量有下限闸(:104 钉至少 60 条),防样本被悄悄删。
  • JwProtocolFixtureMatrixTest(data/jw/JwProtocolFixtureMatrixTest.kt:14)读 detection-pages/*.expected.json 的 _fingerprint 字段,断言 detectProtocolFromHtml 对每个样本返回正确的协议族(:17),并要求 htmlMarkers 至少一条命中。断言数本身有下限(至少 10 个正样本),缺一 = 有协议族的 expected.json 被删。
  • JwProtocolDetectionTest(data/jw/JwProtocolDetectionTest.kt)用 @RunWith(Parameterized) 把 URL/HTML 样本参数化,逐个钉死 detectProtocolFromUrl 与 detectProtocolFromHtml 的返回值,对抗样本(webvpn URL、指纹混淆页)也在参数表里。
  • Schools179CrossValidationTest(data/jw/Schools179CrossValidationTest.kt:19)钉死 schools.json 总数 340(:179)、RFC1918 私网地址永不入清单(:45)、已出售/废弃域永不再出现。名字里的 179 是历史基线,现在已涨到 340,新增学校必须同步抬这个数。

第三类挑几个有代表性的:

  • IrregularOverflowTest(util/IrregularOverflowTest.kt:20)钉死非常规时间课跨午间空隙不被吸附到下午节次——12:30 结束的课不许与 14:00 的课判冲突。
  • ExportImportRoundTripTest / ExportNodesRoundTripTest / ParseNodesLosslessTest(data/parser/)锁死 13 节课表(课程真到 13 节)经过 shareText / JSON / ICS / sleepy-v1 各格式 round-trip 后 nodesPerDay 仍是 13,timeJson 不缩水。设计目标一句话:导入导出都必须无损,「没时间块」不等于「没节次」。
  • WakeUpShareNodesLosslessTest(data/parser/WakeUpShareNodesLosslessTest.kt:13)用一份真实 WakeUp 分享文本(8 门实验课,startNode=11 step=3)锁住课程到达 13 节时不被老表的 10 节声明钳住。
  • Ics28NeuPeriodsTest(data/parser/Ics28NeuPeriodsTest.kt)用 neu-schedule-2026-2027-1.ics(app/src/test/resources/ 下)钉死 NEU 五教学块到节次的映射与连排事件拆分。
  • CourseAliasMigrationTest(data/CourseAliasMigrationTest.kt:20)走 sqlite-jdbc 直连内存库,跑 Room 迁移同一份 MIGRATION_5_6_STATEMENTS,断言 v5 → v6 列加得对、旧行 alias 默认 ''、ALL_MIGRATIONS 链从 3 起步严格连续(防漏登中间版本)。

fixture 组织

两组根目录,各管一摊:

老根 app/src/test/resources/jw_fixtures/(488 文件,git 跟踪)。按协议族分子目录:detection-pages(协议判型样本 + 指纹 oracle)、adversarial(10 个子目录:cross-fp-parsers、empty-semester、encoding-gbk、fp-url-confusion、iframe-nested、image-table、json-boundaries、login-expired、merged-rows、webvpn-urls,共 291 文件,专喂协议识别和解析器的对抗样本)、zf-old-table1、zf-old-variants、zf-new-html、zf-new-kblist、qz-old、qz-base-crazy、qz-br-withnode、wisedu-json、cf-chengfang、hnust-urp、pku-bnuz。detection-pages 顶部的 README.json 写明每个协议的样本清单与断言字段。JwParserFixtureTest 消费其中的 63 个手写脱敏样本,oracle 是同名 *.expected.json 的 courses 数组(九字段,顺序敏感,禁止自动重排)。

新根 app/src/test/resources/jw/fixtures/(16 个协议子目录 + adversarial,共 73 文件)。按学校分目录:neu、ucas(7)、qz_app、boya_pp、bjtu、jou、scu、seu、ustc、zju、chaoxing、eams5(6)、qz_br(3)、qz_ieas、qz_old(4)、qz_with_node(3)。命名沿用「学校或协议 slug + 时间戳」,如 neu/courses.sample.json、ucas/real-detail-0908/(真实采集包分片)、ucas/person-schedule.real-0906.html、boya_pp/ysu-2026-2027-1.json。解析器走 javaClass.classLoader!!.getResourceAsStream("jw/fixtures/<dir>/<file>") 加载。

加新协议 fixture 的标准做法(参考 data/jw/JwNewZfParserTest.kt:24 的注释,该类加载 zf-new/ 顶层的 kblist/html 样本):

  1. 从采集包裁一段真实数据,姓名学号打码,存进 jw/fixtures/<你的协议>/。
  2. 同名写一份解析后的 oracle(走 JwParserFixtureTest 风格就写 *.expected.json 九字段数组;走单 parser 测试就写 *.sample.json)。
  3. 在对应 Jw*ParserTest 里加加载方法与断言;若走 JwParserFixtureTest 风格,在它的 case 表登记一行。
  4. 新协议要进识别层,在 detection-pages/ 下加一份 *.html 与同名 *.expected.json(_fingerprint 里写 urlMarkers / htmlMarkers / expectedProtocol / expectedConfidence / expectedParseResult 五个字段),JwProtocolFixtureMatrixTest 与 JwProtocolDetectionTest 自动纳入。
  5. 跨语言场景(JS 注入侧 + Kotlin 解析侧都要解码同一协议字段)再加一个 *WebViewContractTest,扫 JS 源码断言字段名只在 Kotlin 侧出现。

主资产同步:schools.json 的测试副本 app/src/test/resources/jw/schools.json 必须与 app/src/main/assets/schools.json 1:1。JwNewSchoolsTest 加载副本失败时直接抛错并给出 cp 命令(app/src/test/java/com/lingion/sleepy/data/jw/JwNewSchoolsTest.kt:20),照提示复制即可。

怎么跑

仓库根目录执行:

./gradlew :app:testDebugUnitTest

跑单个测试类:

./gradlew :app:testDebugUnitTest --tests "com.lingion.sleepy.data.jw.JwParserFixtureTest"

跑单个用例:

./gradlew :app:testDebugUnitTest --tests "*UndoCaptureCoverageTest.every public write method captures undo snapshot"

无 Android SDK 也跑得动:这是纯 JVM 测试,只用 junit:4.13.2、org.junit.jupiter:junit-jupiter-params:5.10.2、org.json:json:20231013、org.xerial:sqlite-jdbc:3.53.4.0(app/build.gradle.kts:146-151)。

契约测试红了怎么办

契约测试(*ContractTest、*CoverageTest、*MatrixTest 这一类)的红色信号有几类,处理原则:

fixture 漂移:expected.json / *.sample.json 与 parser 输出对不上。分两路:JwParserFixtureTest 的九字段 oracle 方向固定——字段不符修 parser,禁止改 expected 迎合现状(类头注释原话);单校 parser 测试(如 JwNeuParserTest、JwUcasParserTest)的 fixture 是采集包脱敏件,协议行为确认变化时改 fixture 并在注释写明依据。

源扫描契约红:UndoCaptureCoverageTest、WidgetInfoXmlContractTest、ScheduleViewModeSessionContractTest 这一类几乎一定意味着新加的方法/字段没接进流程。先读测试 KDoc 注释找契约意图,再对照 app/src/main/ 的对应源码。断言消息通常已写明违约后果,照着补接线。

几何/解析引擎红:先确认是哪个几何真值函数被动了——渲染与单测共用一份函数,红通常意味着有人只改了其中一条调用路径。用最小数据复现,先红再绿。

DB 迁移红:CourseAliasMigrationTest 红通常意味着 ALL_MIGRATIONS 漏登中间版本或顺序错乱,「从 3 起步严格连续」的断言会直接点名缺哪一版。

总原则:红色不能靠改测试让它绿。改了行为,必须同步动对应的测试;教务解析必须先写一个失败的测试再改代码让它通过(红→绿),每所学校带 fixture 测试;WebView 内 JS 与 Kotlin 两侧的协议解码必须同步通过对应的 *WebViewContractTest,两边写得不一致,页面表现和导入结果就对不上。提 PR 时勾掉 .github/PULL_REQUEST_TEMPLATE.md 里的两条 checklist:testDebugUnitTest 全绿、lintDebug 无新增错误。

相关页面

源码:app/src/test/java/com/lingion/sleepy/(166 个测试类)、app/src/test/resources/jw_fixtures/(488 文件,老根)、app/src/test/resources/jw/fixtures/(73 文件,新根)、app/src/test/resources/zf-new/、app/build.gradle.kts:62-65(testOptions)、app/build.gradle.kts:146-151(testImplementation)、.github/PULL_REQUEST_TEMPLATE.md(testDebugUnitTest + lintDebug 双闸)。

Clone this wiki locally