Appearance
2.1 本地状态契约
阶段 1 已经有两组固定身份:
text
StageId
pregnancy | baby-0-6 | baby-6-12 | toddler-1-3
RecommendationId
rec-prenatal-checklist | rec-feeding-log | rec-pumpkin-puree
rec-soft-spoon | rec-picture-book阶段 2 新增一个用户要求:切换阶段、收藏或打开详情后,refresh 不能丢状态。Storage 会把静态类型抹成字符串。先定义读写边界,再接 Pinia。
先定 v1 snapshot
Snapshot 只存身份,不复制 Recommendation 内容:
ts
export interface RecommendationStateSnapshot {
readonly version: 1;
readonly selectedStage: StageId;
readonly favoriteIds: readonly RecommendationId[];
readonly historyIds: readonly RecommendationId[];
}固定 version: 1 后,decoder 才能拒绝不认识的结构。标题、摘要和注意事项仍从 client 取得;内容更新时,storage 不会留下一份旧副本。
Decoder 只认 contract
normalizePersistedStageId 不猜输入含义。四个 StageId 原样返回,其余值回 DEFAULT_STAGE_ID:
ts
export function normalizePersistedStageId(value: unknown): StageId {
if (typeof value === "string" && isStageId(value)) {
return value;
}
return DEFAULT_STAGE_ID;
}decodeRecommendationState(raw) 还要处理三组规则:
- 只有五个
RecommendationId能进入收藏和历史;未知值与非字符串被丢弃; - 重复 ID 保留第一次出现的顺序,history 最多 10 条;
- 返回的 snapshot 和两个数组都是冻结副本。
输入示例:
json
{
"version": 1,
"selectedStage": "future-stage",
"favoriteIds": ["rec-pumpkin-puree", "rec-future-item", "rec-pumpkin-puree"],
"historyIds": ["rec-soft-spoon", "rec-soft-spoon"]
}输出使用 DEFAULT_STAGE_ID,收藏只剩 rec-pumpkin-puree,历史只剩 rec-soft-spoon。Decoder 不读 uni;给它 raw value,取回纯数据。
Storage 读取不能只返回默认值
坏 JSON、未来版本和读取异常都可以给 UI 一份默认快照,但写入策略不同。只返回 snapshot 会丢掉这个判断。
ts
export type RecommendationStateReadStatus =
| "loaded"
| "empty"
| "invalid"
| "unsupported-version"
| "read-error";
export interface RecommendationStateReadResult {
readonly status: RecommendationStateReadStatus;
readonly snapshot: RecommendationStateSnapshot;
}| Status | 读到的内容 | 后续写入 |
|---|---|---|
loaded | 合法 v1 | 允许 |
empty | 没有记录 | 允许 |
invalid | 坏 JSON、非对象或缺少 own version | 先用默认值;下一次操作可重建 v1 |
unsupported-version | 对象带 own 非 v1 version | 阻止覆盖;保护状态会重新读取 |
read-error | storage adapter 抛错 | 阻止覆盖;保护状态会重新读取 |
unsupported-version 不能并入 invalid。未来版本可能保存了当前代码不懂的数据。read-error 也不能当成 empty;读不到不等于没有。
保护状态下,hydrate() 会继续读取。结果仍是 read-error 或 unsupported-version 时继续阻止写入;变为 loaded、empty 或 invalid 时解除保护。invalid 按默认状态恢复,下一次用户操作可以写入新的 v1。
分层后,纯函数负责 decode,adapter 负责 getItem / setItem 与异常归类,store 决定提示和写保护。Decoder test 不需要构造 uni 全局对象。
本课程按最终责任重组 2.1:把固定 Recommendation ID domain 与 persistence 收在一个六文件学员提交里。维护仓的实现由多次提交完成(细节见教练侧验收记录)。学员提交不对应某一单次维护 commit;Review 对齐最终 diff、测试和行为。
给 AI 的任务卡
text
Task: add the closed RecommendationId contract and a versioned local-state boundary.
Allowed files:
- apps/web/src/domain/stages.ts
- apps/web/src/domain/stages.test.ts
- apps/web/src/domain/recommendations.ts
- apps/web/src/domain/recommendations.test.ts
- apps/web/src/persistence/recommendation-state.ts
- apps/web/src/persistence/recommendation-state.test.ts
Rules:
- keep exactly the four canonical StageId values;
- define exactly the five canonical RecommendationId values;
- unknown or non-string stage input uses DEFAULT_STAGE_ID;
- filter unknown recommendation ids and preserve first-appearance order;
- keep at most ten history ids;
- return copied, deeply frozen snapshots;
- keep the decoder pure and put uni storage behind an adapter;
- classify reads as loaded, empty, invalid, unsupported-version, or read-error;
- do not weaken, delete, or rewrite existing assertions.
Run:
npm run test:web -- recommendations.test.ts recommendation-state.test.ts如果 diff 出现 page、store、component、package 或 lockfile,先退回范围。扩大 StageId / RecommendationId 为 string 也不能进入下一轮。
Review 第一版:数组还是可变的
AI 初版可能只冻结外层对象:
ts
return Object.freeze({
version: 1,
selectedStage,
favoriteIds,
historyIds,
});readonly 只约束 TypeScript 调用点。运行时仍能修改 snapshot.favoriteIds,而且 helper 把传入数组的引用直接挂到了返回值上。Reviewer 先看 diff,再固定冻结断言:
ts
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.favoriteIds)).toBe(true);
expect(Object.isFrozen(snapshot.historyIds)).toBe(true);实现要在边界处复制并冻结数组:
ts
function createSnapshot(
selectedStage: StageId,
favoriteIds: readonly RecommendationId[],
historyIds: readonly RecommendationId[],
): RecommendationStateSnapshot {
return Object.freeze({
version: 1,
selectedStage,
favoriteIds: Object.freeze([...favoriteIds]),
historyIds: Object.freeze([...historyIds]),
});
}修完局部缺陷后,再读 readRecommendationState 的分支。至少补三项判断:读取抛错得到 read-error;损坏数据得到 invalid;own version 不是 1 得到 unsupported-version。三者都返回默认快照,写保护策略不能相同。
用 fresh 输出批准 GREEN
从教学仓 root 运行:
bash
npm run test:web -- recommendations.test.ts recommendation-state.test.ts
npm run test:web当前 checkpoint 的实际结果:
text
src/domain/recommendations.test.ts (20 tests)
src/persistence/recommendation-state.test.ts (38 tests)
Test Files 2 passed (2)
Tests 58 passed (58)
Test Files 9 passed (9)
Tests 143 passed (143)数字之后还要读断言。Domain 的 20 项守住五个固定 ID;persistence 的 38 项守住 canonical-only stage、ID 过滤去重、history 上限、深冻结、五种 read status、默认 adapter 和读写异常。
精确暂存这组文件
这是按责任重组的学员提交,不是维护仓某个 SHA 的复刻。先检查 unstaged diff,再显式暂存任务卡允许的六个文件:
bash
git status --short
git diff -- \
apps/web/src/domain/stages.ts \
apps/web/src/domain/stages.test.ts \
apps/web/src/domain/recommendations.ts \
apps/web/src/domain/recommendations.test.ts \
apps/web/src/persistence/recommendation-state.ts \
apps/web/src/persistence/recommendation-state.test.ts
git add -- \
apps/web/src/domain/stages.ts \
apps/web/src/domain/stages.test.ts \
apps/web/src/domain/recommendations.ts \
apps/web/src/domain/recommendations.test.ts \
apps/web/src/persistence/recommendation-state.ts \
apps/web/src/persistence/recommendation-state.test.ts
git diff --cached --name-only
git diff --cached --check
git diff --cachedgit diff --cached --name-only 必须正好列出这六个文件。Cached diff 中出现被删除或放宽的既有 assertion,就取消暂存并修正。检查通过后提交:
bash
git commit -m "feat(web): define local recommendation state contract"
git status --short下一步:2.2 Pinia 与本地持久化。