From 4fce9b8d2732719498c9cf94d77092c05a89a14e Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Mon, 21 Sep 2026 20:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=85=88=E4=BF=AE):=20=E6=AF=8F=E8=AF=BE?= =?UTF-8?q?=E9=A1=B5=E9=A6=96=E7=BB=99=E5=87=BA=E5=85=88=E4=BF=AE=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=EF=BC=8C=E5=B9=B6=E5=8A=A0=E9=9D=99=E6=80=81=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E4=B8=8E=E6=B5=8F=E8=A7=88=E5=99=A8=E5=A4=8D=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 零基础学习者从链接、收藏或老师发的地址直接落到第五阶段某一课时,看不出自己缺的是哪一课: 「先修关系」原本只存在于规划文档里,页面上看不到。现在 courseData 为每课登记 prerequisites (只写真正会用到的更早的课,不把路径上的上一课机械照抄,第一课为空),课程目录详情新增 「先修」栏并前移为第 01 节;40 课页面在

正下方给出静态先修块:课号、课名、那一课要会的 一件事,另给「上一课」入口;第一课写明不用先修且不列任何链接。 先修块最初排在图例之后,用真实视口量过才发现不成立:手机竖屏(390×844)下 S5-08 掉到 1060px、整块落在首屏之外,笔记本 1440×900 下 788px 也几乎压到屏幕底部。这轮把它移到

正下方(S5-08 由 1060px 降到 263px),并由测试锁住位置。 验证: - tests/prerequisite-path.test.ts(bun test,5 项):先修指向更早且真实存在的课、是作者挑的 而不是上一课的副本、页面文字与 courseData 一致、块紧贴

、第一课不给链接、首页详情 把先修排在「学习目标」之前并从 courseData 派生课名与「要会的那一件事」 - tests/e2e/prereq-cdp.ts(bun run e2e:prereq,新增):40 课 × 6 项 + 2 项检查器自检 = 242 项, 覆盖先修块整块落在首屏、文字与 courseData 一致、链接全部可达、只用 Tab 走得到且焦点环可见、 真实鼠标点击进得去那一课、手机竖屏同一块仍在首屏内;实跑 242/242(Chrome 153 无头) - 同期回归:bun test 790 通过 / 0 失败(65 文件),bun run e2e:keyboard 328/328, bun run e2e:flow 741/741 - tests/e2e-count-facts.test.ts 把清单与文档里的项数绑回脚本声明,脚本加检查而文档没跟上会失败 - tests/next-lesson.test.ts 的末课断言改为逐条核对课内链接真实存在(先修会合法地回指更早的课, 原来的「页面里不许出现 ../sN」替身会误伤) 文档:README、docs/course-plan.md、docs/feature-checklist.md、docs/roadmap.md(验收矩阵新增 「先修关系可见与可达」一行)、tests/e2e/manual-checklist.md 与新增的 tests/e2e/prereq-manual-checklist.md(复验记录、量测数据、仍需人工确认的部分)。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 + docs/course-plan.md | 4 + docs/feature-checklist.md | 2 + docs/roadmap.md | 2 + index.html | 98 ++++- lessons/s1-01/index.html | 56 +++ lessons/s1-02/index.html | 59 +++ lessons/s1-03/index.html | 59 +++ lessons/s1-04/index.html | 60 +++ lessons/s1-05/index.html | 60 +++ lessons/s1-06/index.html | 60 +++ lessons/s1-07/index.html | 59 +++ lessons/s1-08/index.html | 60 +++ lessons/s2-01/index.html | 60 +++ lessons/s2-02/index.html | 60 +++ lessons/s2-03/index.html | 60 +++ lessons/s2-04/index.html | 60 +++ lessons/s2-05/index.html | 60 +++ lessons/s2-06/index.html | 60 +++ lessons/s2-07/index.html | 60 +++ lessons/s2-08/index.html | 60 +++ lessons/s3-01/index.html | 60 +++ lessons/s3-02/index.html | 59 +++ lessons/s3-03/index.html | 60 +++ lessons/s3-04/index.html | 60 +++ lessons/s3-05/index.html | 60 +++ lessons/s3-06/index.html | 60 +++ lessons/s3-07/index.html | 60 +++ lessons/s3-08/index.html | 60 +++ lessons/s4-01/index.html | 60 +++ lessons/s4-02/index.html | 60 +++ lessons/s4-03/index.html | 60 +++ lessons/s4-04/index.html | 60 +++ lessons/s4-05/index.html | 60 +++ lessons/s4-06/index.html | 59 +++ lessons/s4-07/index.html | 60 +++ lessons/s4-08/index.html | 60 +++ lessons/s5-01/index.html | 60 +++ lessons/s5-02/index.html | 60 +++ lessons/s5-03/index.html | 60 +++ lessons/s5-04/index.html | 60 +++ lessons/s5-05/index.html | 60 +++ lessons/s5-06/index.html | 60 +++ lessons/s5-07/index.html | 60 +++ lessons/s5-08/index.html | 60 +++ package.json | 3 +- scripts/curriculum.ts | 2 + tests/e2e-count-facts.test.ts | 44 +- tests/e2e/manual-checklist.md | 13 +- tests/e2e/prereq-cdp.ts | 596 +++++++++++++++++++++++++++ tests/e2e/prereq-manual-checklist.md | 114 +++++ tests/next-lesson.test.ts | 14 +- tests/prerequisite-path.test.ts | 187 +++++++++ 53 files changed, 3475 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/prereq-cdp.ts create mode 100644 tests/e2e/prereq-manual-checklist.md create mode 100644 tests/prerequisite-path.test.ts diff --git a/README.md b/README.md index da40abe..217e1e2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 五阶段课程路径与阶段切换; - 40 节课件的学习目标、讲授内容、检验清单和练习建议; +- 每节课的先修关系:这一课要先会哪几课、具体要先会哪一件事、上一课是哪一课,都写在页首标题正下方,点得进去; - 本地保存的逐项学习进度,不收集账号或个人信息;浏览器拒绝保存时会明确说明,并可在恢复后重试补写; - 适合外部录课制作时使用的“视频组织建议”和移动端阅读布局。 @@ -282,3 +283,19 @@ CSP_E2E_LESSONS=s3-07 bun tests/e2e/flow-cdp.ts # 只跑一课 新加的课自动纳入检查。步骤与仍需人工确认的部分见 [`tests/e2e/keyboard-manual-checklist.md`](./tests/e2e/keyboard-manual-checklist.md)。 +## 先修关系可见与可达复验 + +「进页面第一眼就知道这节课要先会什么,而且点得回去」同样有自动化复验:`bun run e2e:prereq` 在真实浏览器里 +对全部 40 课逐课复验,242 项检查(40 课各 6 项:5 项笔记本视口 + 1 项手机竖屏,另含脚本自检 2 项)。 + +每课六项:先修块(`

` 正下方那一块)不点任何按钮就已渲染并且整块落在首屏内、块里的文字与 `courseData` +里的先修课一致、每条链接都真的能打开(链接条数等于先修课数加一条上一课)、只用 `Tab` 也走得到且焦点环可见、 +鼠标真的点得进那一课(同一项里先用 `elementFromPoint` 确认没有被其它元素盖住)、换成手机竖屏 390×844 时 +整块仍在首屏内。第一课(S1-01)没有先修,第 3、5 项换成「一条链接都没有」。 + +脚本自带两项自检:先往 S3-04 的先修块里临时插一条指向不存在课程的死链,确认链接可达性检查真的报错了 +(证明这项检查不是空转),再确认页面已还原成仓库里的样子。先修块最初排在图例之后,手机竖屏上会掉到 1060px +(首屏之外),这一项就是那时量出来的;现在它排在标题正下方,同时由 `bun test` 里的 +[`tests/prerequisite-path.test.ts`](./tests/prerequisite-path.test.ts) 锁住位置与文字。步骤、量测数据与仍需 +人工确认的部分见 [`tests/e2e/prereq-manual-checklist.md`](./tests/e2e/prereq-manual-checklist.md)。 + diff --git a/docs/course-plan.md b/docs/course-plan.md index 773a862..9c17532 100644 --- a/docs/course-plan.md +++ b/docs/course-plan.md @@ -14,6 +14,10 @@ “必会”指 CSP-J 路线的核心;“拓展”只能作为能力边界说明,不能代替前置课的练习。 +每节课的页面在标题下方直接写明它的先修关系:要先会哪几课、具体要先会的那一件事,以及上一课入口。 +先修数据只写在 `index.html` 的 `courseData` 里,页面与课程目录详情都从那里取,改一处不会漏改另一处; +第一课明确写明不用先修。 + ## 第一阶段:建立程序感 目标:从“会跟着敲代码”进步到“能独立写出、运行并修正一个小程序”。 diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 8f78dcc..01cb4d9 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -8,6 +8,7 @@ | --- | --- | --- | | 五阶段学习路径 | 已完成 | 可切换阶段并查看阶段目标与课程数量 | | 40 节 CSP-J C++ 课件目录 | 已完成 | 每节含目标、内容、检验清单、视频组织建议和练习建议 | +| 每节课的先修关系可见 | 已完成 | 每课页首标题正下方列出先修课号、课名与要会的那一件事,并给上一课入口;课程目录详情面板同步显示同一份数据 | | 单课件详情阅读 | 已完成 | 点击任意课件卡片即可切换详情;键盘上下方向键也可切换 | | 交互动作反馈 | 已完成 | 切换阶段、选择课件、筛选和修改进度后显示即时状态提示 | | 逐项学习检验 | 已完成 | 可勾选、撤销勾选或一键完成当前课 | @@ -112,6 +113,7 @@ | 40 节随机在线测试 | 已完成 | 每课从 5 至 7 道已通过校验的题库随机组卷;选择题数量严格多于非选择题;完成后展示得分、解析和复习出口;空题库或题目无效时明确告知并保留进度;每题把检查目标绑到本课学习目标原文并另起一行说明本题检查,未通过时按学习目标汇总错题 | | 题库与组卷质量控制 | 已完成 | 每道题关联课程 ID、学习目标、难度、题型、正确答案、解析与来源;同次不重复,试卷用种子标识复现;题目统一标记为已通过机器校验。难度档位统一为必会 `required` / 建议掌握 `recommended` / 拓展 `stretch`,由 `tests/lesson-contract.test.ts` 逐课校验;抽出来的那 3 道题另由 `tests/quiz-paper-contract.test.ts` 在 256 个种子下校验题数、同次不重复、只出选择题、同一试卷标识可复现、题库内每题都抽得到,以及抽题不会就地改写题库 | | 学习尝试恢复 | 已完成 | 保存失败(含会话中途失败)当次会话立刻可见、可点“重试保存”;恢复后说明“本地保存已恢复”并把期间待写进度补写落盘;词条表载入失败同样当场说明、可点“重新载入词条表”,恢复后回到刚才点开的词条。两类失败都不伪造成功 | +| 先修关系可见与可达 | 已完成 | 全部 40 课都由 `bun run e2e:prereq` 在无头 Chrome 里复验(242 项检查:40 课各 6 项,5 项笔记本视口 + 1 项手机竖屏,另含脚本自检 2 项),覆盖先修块整块落在首屏、文字与 `courseData` 一致、链接全部可达、键盘走得到焦点环可见、真实鼠标点击真的进得去,以及手机竖屏下的同一块 | | 键盘可达与焦点可见 | 已完成 | 全部 40 课都由 `bun run e2e:keyboard` 在无头 Chrome 里发真实按键事件复验(328 项检查:结构检查每课 8 项 + S1-02 与 S3-01 的键盘深路径每课 4 项),覆盖 Tab 绕圈全程可见焦点环、无正数 tabindex 打乱顺序、音效开关 Space 可切换、词条 Enter/Escape 与焦点归还、步进门闩挡得住、提交后焦点交给结果区;`tests/keyboard-access-contract.test.ts` 在 `bun test` 里逐课锁住两处焦点恢复动作(作答重画题面、提交成功),焦点环观感与读屏语气仍待人工确认 | | 可复制的代码模板与错误对照 | 下一步 | 帮助初学者辨识常见 C++ 错误;不在浏览器端执行不受控代码 | | 教师备课打印版 | 下一步 | 便于课堂使用;默认不包含学生账号或行为追踪 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 31c2fbf..69fac01 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,6 +36,7 @@ - **五阶段课程导航**:`index.html` 已提供第一阶段至第五阶段的切换、筛选和 40 节课浏览。 - **单课件学习面板**:`index.html` 的课件详情会显示每节课的学习目标、具体内容、检验清单、视频组织建议和练习建议。 +- **每节课的先修关系**:每课 `lessons/<课程-id>/index.html` 的标题正下方列出要先会的课号、课名与该课要会的那一件事,并给上一课入口;第一课说明「不用先修」且不列任何链接,课程目录的详情面板显示同一份数据,页面上写的与 `courseData` 不一致会被校验当场挡下。 - **本地学习进度**:课件逐项检验结果保存在浏览器 `localStorage`;不可用时显示明确提示并继续允许当前会话学习。保存失败发生在会话中途(例如配额用尽)时会立刻可见、可重试,恢复后说明数据状态并补写待保存的进度。 - **即时交互反馈**:阶段、课件、筛选和进度操作均会显示明确状态提示,避免用户把页面更新误认为无响应。 - **Bun 开发工具链**:`bun run dev` 提供本地静态课程路由,`bun run scaffold:lessons` 创建课程目录,`bun test` 执行无依赖校验。 @@ -196,6 +197,7 @@ | 学习尝试恢复(保存失败可见、可重试、恢复后补写) | 中 | ✅ 首页与全部 40 课的保存成功路径逐课验证 | ✅ 会话中途写入被浏览器拒绝时当次立刻说明“没有保存成功、刷新后不会恢复进度”,不伪装成已保存,互动不中断 | 不适用:无账号 | ✅ 提示里提供“重试保存”;恢复后提示消失、说明“本地保存已恢复”并把期间待写进度补写落盘 | [`tests/storage-recovery.test.ts`](../tests/storage-recovery.test.ts)、[`E2E-06`](../tests/e2e/manual-checklist.md#e2e-06-会话中途保存失败与恢复)、[`E2E-S1-01-06`](../tests/e2e/s1-01-manual-checklist.md#e2e-s1-01-06-会话中途保存失败与恢复) | | 微编程浏览器自动化复验(S1-02 至 S1-08、S2-01 至 S2-08、S3-01 至 S3-08、S4-01 至 S4-08、S5-01 至 S5-08 样板) | 中 | ✅ 659 项检查覆盖 39 课手写填空(每课 16 至 17 项),`bun run e2e:s1-typed` / `bun run e2e:s2-typed` / `bun run e2e:s3-typed` / `bun run e2e:s4-typed` / `bun run e2e:s5-typed` / `bun run e2e:typed` 自动启停浏览器与服务 | ✅ 每课验证空提交、全角符号、语义写错(S1:把更新写成 `==` 而不是 `=` / 漏括号写成 `a + b / 2` / 连续读入写成 `cin >> a, b;` / `>=` 丢掉等于 / 循环上界写成 `i < n` / 内层循环变量写成 `r` / 多写字符写成 `;n`;S2 起:越界下标 / 漏括号 / 参数或大小写不符 / 写引用或指针 / 取错成员 / 换同一格或跳格 / 该取个位写成 `x / 10` / 递归括号里还是 `n` 到不了边界 / 中点漏括号写成 `left + right / 2` / 前缀和漏减 1 写成 `sum[i]` / 窗口两步写成 `sum += a[right]; right++` / 贪心排序按开始时间排 `by_start` / 弹栈写成只看不弹的 `st.top()` / DP 转移把两类走法相乘 / 剪枝写成 `if (sum >= target) return;` / 计数写成 `cnt[score] = 1;` 覆盖已数次数 / 转移漏写金额 `max(dp[i - 1], dp[i - 2])` / 干净输出写成 `cin >> n + 1;` / 累加写成 `sum = a[i];` 覆盖掉前面的和 / 边界把允许的 0 写成 `> 0` / 防止重复访问写成条件写反的 `if (!visited[u]) return;` / 保底条件写成子任务 2 上限 `if (n <= 100000)` / 分段输出把方向写成读入 `cin >> i >> prefix;` / 累加变量仍写 `int sum = 0;` 装不下 / 区间和漏减 1 写成 `sum[r] - sum[l]` / 空数组判断写成 `if (n == 1)` / 循环范围漏掉等号写成 `i < n` / 按审题圈出的范围开数组正好差一格写成 `int a[100000];` / 交叉检查漏写 `1LL` 让 `n * (n + 1)` 溢出 / 计数数组清零写成标量 `int cnt[105] = 0;`)三类失败路径均给出具体指正且不算完成 | 不适用:无账号 | ✅ 刷新后恢复草稿与完成状态;重置可清空;输入内容原样保留且不会被当成 HTML 或正确答案 | [`tests/e2e/s2-typed-cdp.ts`](../tests/e2e/s2-typed-cdp.ts)、[`S2 复验记录`](../tests/e2e/s2-01-manual-checklist.md#自动化复验记录)、[`S3 复验记录`](../tests/e2e/s3-01-manual-checklist.md#自动化复验记录)、[`S4 复验记录`](../tests/e2e/s4-01-manual-checklist.md#自动化复验记录)、[`S5 复验记录`](../tests/e2e/s5-02-manual-checklist.md#自动化复验记录)、[`S1 复验记录`](../tests/e2e/s1-02-manual-checklist.md#自动化复验记录) | | 单课浏览器自动化复验(S1-01 样板) | 中 | ✅ 88 项检查覆盖八个场景,`bun run e2e:s1-01` 自动启停浏览器与服务 | ✅ 存储不可用、会话中途保存失败、词条表载入失败、词条面板脚本未载入与题库异常(空题库、答案越界)五条失败路径在真实浏览器中验证 | 不适用:无账号 | ✅ 脚本临时改写题库后会强制还原并断言“临时改动已还原”;保存失败后重试会补写落盘;词条表恢复后回到刚才点开的词条;面板脚本 404 时当场说明且不中断其他互动 | [`tests/e2e/s1-01-cdp.ts`](../tests/e2e/s1-01-cdp.ts)、[`复验记录`](../tests/e2e/s1-01-manual-checklist.md#自动化复验记录) | +| 先修关系可见与可达(全部 40 课样板) | 中 | ✅ 全部 40 节课逐课在真实浏览器里复验「进页面第一眼就知道要先会什么、而且点得回去」:`bun run e2e:prereq` 242 项检查(40 课各 6 项:5 项笔记本视口检查 + 1 项手机竖屏检查;另含脚本自检 2 项。2026-09-21 Chrome 153 无头) | ✅ 指向不存在课程的死链、链接被其它元素盖住、先修块被挤出首屏、键盘走不到、页面文字与 `courseData` 不一致都会失败;脚本自检会先插一条死链并确认它被抓到,检查器空转同样失败 | 不适用:无账号 | 不适用:只读导航,不修改状态 | [`tests/prerequisite-path.test.ts`](../tests/prerequisite-path.test.ts)、[`tests/e2e/prereq-cdp.ts`](../tests/e2e/prereq-cdp.ts)、[`tests/e2e/prereq-manual-checklist.md`](../tests/e2e/prereq-manual-checklist.md) | | 键盘可达与焦点可见(全部 40 课样板) | 中 | ✅ 全部 40 课逐课复验「不用鼠标也能走完,而且始终看得见焦点在哪」:`bun run e2e:keyboard` 在真实浏览器里发真实按键事件,328 项检查(结构检查每课 8 项 + S1-02 与 S3-01 的键盘深路径每课 4 项),覆盖 `Tab` 绕圈每个停靠点都有可见焦点环、没有正数 `tabindex` 打乱自然顺序、原生控件没被 `tabindex="-1"` 移出 `Tab` 顺序、没有「看着能点、键盘够不到」的元素、音效开关 `Space` 能切换且 `aria-pressed` 与文案同步、词条 `Enter` 打开 / `Esc` 关闭并把焦点交还刚才那个词条、步进轨道每一步(含锁着的)都在 `Tab` 顺序里 | ✅ 焦点丢失这条高风险失败路径逐课看住:作答重画题面后必须把焦点交还刚答的那一项、提交成功后必须交给结果区 `#quizResult`(此前两处都会掉回文档主体);`bun test` 里的 `tests/keyboard-access-contract.test.ts` 反向锁住这两个恢复动作被删掉或挪错位置,按 `courseData` 派生所以新课自动纳入 | 不适用:无账号 | ✅ 步进门闩 `Tab` 够得到但 `Enter` 与 `Space` 都推不动进度,面板照旧藏着且状态区说明原因;词条面板关闭后焦点回到刚才那个词条而不是文档开头;清掉本地存储后键盘路径仍可重走 | [`tests/keyboard-access-contract.test.ts`](../tests/keyboard-access-contract.test.ts)、[`tests/e2e/keyboard-cdp.ts`](../tests/e2e/keyboard-cdp.ts)、[`键盘清单`](../tests/e2e/keyboard-manual-checklist.md#自动化复验记录) | | S1-01 引导式在线学习界面 | 中 | ✅ 已复验:88 项浏览器检查(`bun run e2e:s1-01`,2026-09-21 Chrome 153 无头) | ✅ 错误选择、未解锁任务、存储不可用、会话中途保存失败、词条表载入失败与词条面板脚本未载入均给出明确反馈 | 不适用:无账号 | ✅ 刷新后恢复任务与答题状态;存储失败时说明限制并可重试保存;词条表失败时可重新载入;面板脚本载入失败时给出重新载入本课入口 | [`S1-01 E2E`](../tests/e2e/s1-01-manual-checklist.md#自动化复验记录)、[`tests/e2e/s1-01-cdp.ts`](../tests/e2e/s1-01-cdp.ts) | | S1-02 引导式在线学习界面 | 中 | ✅ 微编程 17 项浏览器检查已复验(2026-09-21,`bun run e2e:s1-typed`);整课推进与随机小测已复验(`bun run e2e:flow`,每课 19 项浏览器检查,2026-09-21 Chrome 153 无头) | ✅ 错误类型、跳过声明/赋值和存储不可用均给出明确反馈 | 不适用:无账号 | ✅ 刷新后恢复任务与答题状态;存储失败时说明限制 | [`S1-02 E2E`](../tests/e2e/s1-02-manual-checklist.md#自动化复验记录)、[`E2E-s1-typed`](../tests/e2e/s2-typed-cdp.ts)、[`E2E-flow`](../tests/e2e/flow-cdp.ts) | diff --git a/index.html b/index.html index ef25636..89c521e 100644 --- a/index.html +++ b/index.html @@ -61,7 +61,8 @@ button:focus-visible, select:focus-visible, - input:focus-visible { + input:focus-visible, + a:focus-visible { outline: 3px solid var(--orange-soft); outline-offset: 3px; } @@ -568,6 +569,7 @@ .chip-list, .content-list, + .prereq-list, .checklist { display: grid; gap: 9px; @@ -606,6 +608,36 @@ top: 0.39rem; } + /* 先修:一眼看出「开始之前得先会什么」,每条都能直接点进那一课。 */ + #prerequisiteList li { + position: relative; + padding-left: 18px; + color: #3e5049; + font-size: 0.86rem; + line-height: 1.65; + } + + #prerequisiteList li::before { + position: absolute; + left: 0; + color: var(--orange); + content: "◆"; + font-size: 0.58rem; + top: 0.39rem; + } + + #prerequisiteList a { + color: var(--teal); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + #prerequisiteList a:hover { + color: var(--ink-deep); + } + .check-item { display: grid; grid-template-columns: 20px 1fr; @@ -980,23 +1012,28 @@

+
+

01先修

+
    +
    +
    -

    01学习目标

    +

    02学习目标

      -

      02具体内容

      +

      03具体内容

        -

        03检验清单

        +

        04检验清单

        -

        04外部视频制作建议

        +

        05外部视频制作建议

          @@ -1058,6 +1095,7 @@

          04外部视频制作建 { id: "S1-01", phase: 0, + prerequisites: [], title: "第一行程序与编译", duration: "45 分钟", objectives: ["说清源文件、编译与运行的关系", "独立写出并输出多行文字", "看懂最常见的编译报错位置"], @@ -1069,6 +1107,7 @@

          04外部视频制作建 { id: "S1-02", phase: 0, + prerequisites: ["S1-01"], title: "变量与数据类型", duration: "50 分钟", objectives: ["用变量记录题目中的量", "区分整数、实数与字符", "避免随手选择不合适的类型"], @@ -1080,6 +1119,7 @@

          04外部视频制作建 { id: "S1-03", phase: 0, + prerequisites: ["S1-02"], title: "表达式与运算顺序", duration: "50 分钟", objectives: ["把文字公式翻译成 C++ 表达式", "掌握整除与取模", "用括号消除优先级歧义"], @@ -1091,6 +1131,7 @@

          04外部视频制作建 { id: "S1-04", phase: 0, + prerequisites: ["S1-02", "S1-03"], title: "输入输出与格式", duration: "45 分钟", objectives: ["正确读取多个输入数据", "按题目要求输出格式", "排查格式导致的失分"], @@ -1102,6 +1143,7 @@

          04外部视频制作建 { id: "S1-05", phase: 0, + prerequisites: ["S1-03", "S1-04"], title: "条件判断", duration: "55 分钟", objectives: ["根据条件选择不同分支", "正确组合比较与逻辑条件", "覆盖等于边界的情况"], @@ -1113,6 +1155,7 @@

          04外部视频制作建 { id: "S1-06", phase: 0, + prerequisites: ["S1-02", "S1-04"], title: "重复执行与循环", duration: "55 分钟", objectives: ["用循环解决重复计算", "理解循环变量的变化", "主动检查死循环风险"], @@ -1124,6 +1167,7 @@

          04外部视频制作建 { id: "S1-07", phase: 0, + prerequisites: ["S1-06"], title: "嵌套循环与规律输出", duration: "55 分钟", objectives: ["看懂二维重复过程", "区分内外层循环职责", "处理简单提前结束"], @@ -1135,6 +1179,7 @@

          04外部视频制作建 { id: "S1-08", phase: 0, + prerequisites: ["S1-01", "S1-06"], title: "调试与书写习惯", duration: "50 分钟", objectives: ["区分三类常见错误", "养成可检查的代码书写习惯", "用小数据定位问题"], @@ -1146,6 +1191,7 @@

          04外部视频制作建 { id: "S2-01", phase: 1, + prerequisites: ["S1-02", "S1-06"], title: "数组与下标", duration: "55 分钟", objectives: ["存储一组同类数据", "安全遍历数组", "避免下标越界"], @@ -1157,6 +1203,7 @@

          04外部视频制作建 { id: "S2-02", phase: 1, + prerequisites: ["S1-02", "S2-01"], title: "字符、字符串与编码", duration: "55 分钟", objectives: ["正确处理字符序列", "区分字符与字符串", "逐个访问字符串内容"], @@ -1168,6 +1215,7 @@

          04外部视频制作建 { id: "S2-03", phase: 1, + prerequisites: ["S1-02", "S1-03"], title: "函数与分解", duration: "60 分钟", objectives: ["把独立逻辑拆成函数", "正确使用参数和返回值", "降低重复代码"], @@ -1179,6 +1227,7 @@

          04外部视频制作建 { id: "S2-04", phase: 1, + prerequisites: ["S1-02", "S2-03"], title: "作用域与参数", duration: "50 分钟", objectives: ["理解变量在何处可见", "避免同名变量混乱", "认识何时需要修改原变量"], @@ -1190,6 +1239,7 @@

          04外部视频制作建 { id: "S2-05", phase: 1, + prerequisites: ["S2-01", "S2-03"], title: "结构体与题目建模", duration: "60 分钟", objectives: ["把多项相关信息组合成记录", "读写结构体成员", "用记录数组表示多名对象"], @@ -1201,6 +1251,7 @@

          04外部视频制作建 { id: "S2-06", phase: 1, + prerequisites: ["S2-01", "S2-05"], title: "排序与比较规则", duration: "60 分钟", objectives: ["让数据按题意排序", "理解比较规则", "验证重复元素的处理"], @@ -1212,6 +1263,7 @@

          04外部视频制作建 { id: "S2-07", phase: 1, + prerequisites: ["S1-06", "S2-01"], title: "枚举与模拟", duration: "60 分钟", objectives: ["把有限可能逐一检查", "按题面顺序更新状态", "避免漏情况"], @@ -1223,6 +1275,7 @@

          04外部视频制作建 { id: "S2-08", phase: 1, + prerequisites: ["S1-07", "S2-07"], title: "复杂度与测试点", duration: "55 分钟", objectives: ["用规模判断程序能否通过", "建立 O(n) 与 O(n²) 的直觉", "主动设计边界测试"], @@ -1234,6 +1287,7 @@

          04外部视频制作建 { id: "S3-01", phase: 2, + prerequisites: ["S1-03", "S1-06"], title: "数学与数位处理", duration: "55 分钟", objectives: ["用整除和取模处理数字", "理解循环不变量", "掌握最大公约数的基础写法"], @@ -1245,6 +1299,7 @@

          04外部视频制作建 { id: "S3-02", phase: 2, + prerequisites: ["S2-03"], title: "递归与分治直觉", duration: "60 分钟", objectives: ["用更小的同类问题描述过程", "写清递归边界", "追踪有限层调用"], @@ -1256,6 +1311,7 @@

          04外部视频制作建 { id: "S3-03", phase: 2, + prerequisites: ["S2-01", "S2-06"], title: "二分查找", duration: "60 分钟", objectives: ["识别有序与单调前提", "维护左右边界", "处理首尾答案"], @@ -1267,6 +1323,7 @@

          04外部视频制作建 { id: "S3-04", phase: 2, + prerequisites: ["S1-06", "S2-01"], title: "前缀和与区间查询", duration: "60 分钟", objectives: ["预处理连续数据", "用公式快速查询区间和", "避免下标偏移错误"], @@ -1278,6 +1335,7 @@

          04外部视频制作建 { id: "S3-05", phase: 2, + prerequisites: ["S2-01", "S3-04"], title: "双指针与滑动窗口", duration: "60 分钟", objectives: ["用两个位置维护一个连续区间", "判断指针移动时机", "保持窗口条件"], @@ -1289,6 +1347,7 @@

          04外部视频制作建 { id: "S3-06", phase: 2, + prerequisites: ["S2-06", "S2-07"], title: "贪心选择", duration: "60 分钟", objectives: ["提出局部选择规则", "用反例检查规则", "理解贪心并非总是可用"], @@ -1300,6 +1359,7 @@

          04外部视频制作建 { id: "S3-07", phase: 2, + prerequisites: ["S1-06", "S2-01"], title: "栈、队列与过程顺序", duration: "55 分钟", objectives: ["区分后进先出和先进先出", "选择合适容器", "完成基础括号匹配"], @@ -1311,6 +1371,7 @@

          04外部视频制作建 { id: "S3-08", phase: 2, + prerequisites: ["S2-01", "S3-02"], title: "动态规划初步", duration: "60 分钟", objectives: ["定义状态含义", "写出小问题到大问题的转移", "重视初始化"], @@ -1322,6 +1383,7 @@

          04外部视频制作建 { id: "S4-01", phase: 3, + prerequisites: ["S1-01", "S1-04"], title: "CSP-J 题面与程序规范", duration: "45 分钟", objectives: ["按竞赛要求提交干净程序", "从题面圈出数据范围", "避免格式性失分"], @@ -1333,6 +1395,7 @@

          04外部视频制作建 { id: "S4-02", phase: 3, + prerequisites: ["S2-05", "S4-01"], title: "读题与建模卡", duration: "55 分钟", objectives: ["把题意拆成结构化信息", "识别已知、未知和约束", "在编码前列出边界"], @@ -1344,6 +1407,7 @@

          04外部视频制作建 { id: "S4-03", phase: 3, + prerequisites: ["S2-07", "S3-07"], title: "模拟题专项", duration: "60 分钟", objectives: ["正确还原多步骤规则", "按顺序更新状态", "用自造样例验证过程"], @@ -1355,6 +1419,7 @@

          04外部视频制作建 { id: "S4-04", phase: 3, + prerequisites: ["S3-02", "S3-07"], title: "搜索与剪枝", duration: "65 分钟", objectives: ["把选择过程画成搜索树", "实现回溯边界", "判断安全的剪枝条件"], @@ -1366,6 +1431,7 @@

          04外部视频制作建 { id: "S4-05", phase: 3, + prerequisites: ["S2-06", "S3-03"], title: "排序、查找与映射", duration: "60 分钟", objectives: ["根据数据范围选择工具", "处理重复数据", "在排序后继续扫描或查找"], @@ -1377,6 +1443,7 @@

          04外部视频制作建 { id: "S4-06", phase: 3, + prerequisites: ["S3-08"], title: "DP 经典模型", duration: "65 分钟", objectives: ["识别阶段性最优决策", "写出状态转移", "处理不可达或初始状态"], @@ -1388,6 +1455,7 @@

          04外部视频制作建 { id: "S4-07", phase: 3, + prerequisites: ["S3-07", "S4-04"], title: "图与遍历基础", duration: "60 分钟", objectives: ["用点和边表示关系", "完成基础 DFS/BFS", "防止重复访问"], @@ -1399,6 +1467,7 @@

          04外部视频制作建 { id: "S4-08", phase: 3, + prerequisites: ["S2-08", "S4-01"], title: "子任务与得分策略", duration: "50 分钟", objectives: ["在限时中优先获得稳定分数", "识别可做子任务", "安排检查时间"], @@ -1410,6 +1479,7 @@

          04外部视频制作建 { id: "S5-01", phase: 4, + prerequisites: ["S4-02", "S4-08"], title: "限时解题流程", duration: "55 分钟", objectives: ["形成固定解题顺序", "在编码前留下建模痕迹", "为检查预留时间"], @@ -1421,6 +1491,7 @@

          04外部视频制作建 { id: "S5-02", phase: 4, + prerequisites: ["S1-08", "S5-01"], title: "系统化调试", duration: "60 分钟", objectives: ["按证据缩小错误范围", "构造最小复现", "记录修复原因"], @@ -1432,6 +1503,7 @@

          04外部视频制作建 { id: "S5-03", phase: 4, + prerequisites: ["S1-02", "S2-08"], title: "C++ 常见陷阱复盘", duration: "60 分钟", objectives: ["识别语言层面的失分点", "根据范围选择 `long long`", "系统检查数组和表达式"], @@ -1443,6 +1515,7 @@

          04外部视频制作建 { id: "S5-04", phase: 4, + prerequisites: ["S4-08", "S5-01"], title: "80 分策略", duration: "55 分钟", objectives: ["先实现可验证的基线", "利用特殊情况和子任务", "避免伪优化"], @@ -1454,6 +1527,7 @@

          04外部视频制作建 { id: "S5-05", phase: 4, + prerequisites: ["S5-01", "S5-04"], title: "模拟赛一:实现与自测", duration: "60 分钟", objectives: ["在题组中执行固定流程", "保存自测数据", "记录时间和阻塞点"], @@ -1465,6 +1539,7 @@

          04外部视频制作建 { id: "S5-06", phase: 4, + prerequisites: ["S5-02", "S5-05"], title: "模拟赛复盘:错误分类", duration: "55 分钟", objectives: ["从失分中抽取可改规则", "区分不同错误类型", "重做关键题验证修复"], @@ -1476,6 +1551,7 @@

          04外部视频制作建 { id: "S5-07", phase: 4, + prerequisites: ["S5-05", "S5-06"], title: "模拟赛二:稳定性", duration: "60 分钟", objectives: ["用新题验证流程改进", "比较两次表现", "识别已经稳定的习惯"], @@ -1487,6 +1563,7 @@

          04外部视频制作建 { id: "S5-08", phase: 4, + prerequisites: ["S5-06", "S5-07"], title: "CSP-J 总复习与后续边界", duration: "50 分钟", objectives: ["整理个人知识地图", "完成赛前检查清单", "明确 CSP-J 后的学习边界"], @@ -1667,6 +1744,17 @@

          04外部视频制作建 document.querySelector("#detailTitle").textContent = lesson.title; document.querySelector("#detailSubtitle").textContent = `当前检验:${completed.length}/${lesson.checklist.length} 项 · 先完成本课清单,再进入下一课。`; document.querySelector("#lessonTiming").textContent = lesson.duration; + // 先修从 courseData 派生:只写真正会用到的更早的课,第一课明确没有先修。 + document.querySelector("#prerequisiteList").innerHTML = + lesson.prerequisites.length > 0 + ? lesson.prerequisites + .map((id) => courseData.find((item) => item.id === id)) + .map( + (item) => + `
        • ${item.id} ${item.title}:${item.objectives[0]}
        • `, + ) + .join("") + : "
        • 这是全课程第一课:不用先修,会用键盘和浏览器就能开始。
        • "; document.querySelector("#objectiveList").innerHTML = lesson.objectives.map((item) => `
        • ${item}
        • `).join(""); document.querySelector("#contentList").innerHTML = lesson.contents.map((item) => `
        • ${item}
        • `).join(""); document.querySelector("#videoList").innerHTML = `
        • ${lesson.video}
        • `; diff --git a/lessons/s1-01/index.html b/lessons/s1-01/index.html index 5372c8f..8b285eb 100644 --- a/lessons/s1-01/index.html +++ b/lessons/s1-01/index.html @@ -238,6 +238,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .mission-slip { z-index: 1; align-self: end; @@ -1187,6 +1239,10 @@

          让你的第一行 C++ 跑起来

          +

          这一课不要求你背很多单词。你只要完成四个小任务:看懂程序入口、走一遍编译流程、修复一个小错误,再做一套随机小测。

          diff --git a/lessons/s1-02/index.html b/lessons/s1-02/index.html index d8924df..56027ae 100644 --- a/lessons/s1-02/index.html +++ b/lessons/s1-02/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1308,6 +1360,13 @@

          给比赛成绩选对 盒子

          +

          这一课只练一件事:用变量把题目里的量装进合适的类型。完成四个小任务:选类型、更新变量、分清字符和大整数,再做一套随机小测。

          diff --git a/lessons/s1-03/index.html b/lessons/s1-03/index.html index 18c7dd8..5e7275c 100644 --- a/lessons/s1-03/index.html +++ b/lessons/s1-03/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1349,6 +1401,13 @@

          把口算写成 算式

          +

          这一课只练一件事:把文字公式翻译成 C++ 。完成四个小任务:选对公式、看清整除和余数、主动加括号,再做一套随机小测。

          diff --git a/lessons/s1-04/index.html b/lessons/s1-04/index.html index e3f5774..e0aa8f5 100644 --- a/lessons/s1-04/index.html +++ b/lessons/s1-04/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1349,6 +1401,14 @@

          让输入输出对上 样例

          +

          这一课只练一件事:用 读入、用 按格式输出。完成四个小任务:选对读入、看清空格和换行、拿掉多余提示语,再做一套随机小测。

          diff --git a/lessons/s1-05/index.html b/lessons/s1-05/index.html index 54e3e84..d60e81a 100644 --- a/lessons/s1-05/index.html +++ b/lessons/s1-05/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1370,6 +1422,14 @@

          让分数走进对的 分支

          +

          这一课只练一件事:用 根据条件选择不同分支。完成四个小任务:选对判断、走一遍 60 分边界、分清 和 ,再做一套随机小测。

          diff --git a/lessons/s1-06/index.html b/lessons/s1-06/index.html index b125935..8a6f29e 100644 --- a/lessons/s1-06/index.html +++ b/lessons/s1-06/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1349,6 +1401,14 @@

          让重复计算走进 循环

          +

          这一课只练一件事:用 把重复计算写成循环。完成四个小任务:选对循环、走一遍累加、避免 ,再做一套随机小测。

          diff --git a/lessons/s1-07/index.html b/lessons/s1-07/index.html index 69aa8cd..d76dddd 100644 --- a/lessons/s1-07/index.html +++ b/lessons/s1-07/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1349,6 +1401,13 @@

          让规律输出走进 嵌套循环

          +

          这一课只练一件事:用 看懂二维重复。完成四个小任务:分清内外层、走一遍 3 行 3 列、认识 ,再做一套随机小测。

          diff --git a/lessons/s1-08/index.html b/lessons/s1-08/index.html index 4240b14..04d524a 100644 --- a/lessons/s1-08/index.html +++ b/lessons/s1-08/index.html @@ -951,6 +951,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1349,6 +1401,14 @@

          把程序写成可检查的样子

          +

          这一课只练一件事:建立“先看报错、再改写法、再用小数据验证”的工作流。完成四个小任务:分清 、 和 ,定位一处语法错,再用小数据和 检查。

          diff --git a/lessons/s2-01/index.html b/lessons/s2-01/index.html index b3e53dc..b382564 100644 --- a/lessons/s2-01/index.html +++ b/lessons/s2-01/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          把一组分数放进格子里

          +

          这一课只练一件事:用 装一组同类数据,并安全使用 。完成四个小任务:选出怎么声明 int a[n],走一遍 a[0] 到 a[n - 1],再遍历求最大值。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-02/index.html b/lessons/s2-02/index.html index e176ea6..e3dc7d0 100644 --- a/lessons/s2-02/index.html +++ b/lessons/s2-02/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          把字母和单词分开装

          +

          这一课只练一件事:分清 和 ,再按 逐个访问字符。完成四个小任务:选出 char c = 'A' 和 string s = "Ada",走一遍 s[0] 到 s[n - 1],再统计指定字符。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-03/index.html b/lessons/s2-03/index.html index 09fc648..63bf3d9 100644 --- a/lessons/s2-03/index.html +++ b/lessons/s2-03/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          把重复的判断抽成函数

          +

          这一课只练一件事:把重复或独立的判断抽成 ,用 传入数据,用 交回结果。完成四个小任务:选出 bool isEven(int x) 和 return x % 2 == 0,走一遍 isEven(4),再把闰年判断抽成函数。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-04/index.html b/lessons/s2-04/index.html index d21aa20..1a6fb65 100644 --- a/lessons/s2-04/index.html +++ b/lessons/s2-04/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          看清变量看得见的范围

          +

          这一课只练一件事:弄清变量的 ,看懂 改的是副本。完成四个小任务:选出 出了函数看不见,确认 addOne(n) 不会改外面的 n,再认识同名和 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-05/index.html b/lessons/s2-05/index.html index b655e22..6376e5b 100644 --- a/lessons/s2-05/index.html +++ b/lessons/s2-05/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          把一个人的信息放进一张卡

          +

          这一课只练一件事:用 把姓名、分数这些相关信息放进同一张记录卡。完成四个小任务:选出该用 struct 而不是一堆散变量,按顺序填写 stu.name 和 stu.score,再认识 和 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-06/index.html b/lessons/s2-06/index.html index 2714db6..306b382 100644 --- a/lessons/s2-06/index.html +++ b/lessons/s2-06/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          让数据按题意排好

          +

          这一课只练一件事:用 决定谁该排前面,再用相邻 把数组排整齐。完成四个小任务:选出 ,走一遍 3, 1, 2,再认识 和 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-07/index.html b/lessons/s2-07/index.html index 94f2e9f..999c226 100644 --- a/lessons/s2-07/index.html +++ b/lessons/s2-07/index.html @@ -994,6 +994,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1353,6 +1405,14 @@

          把过程一步步走出来

          +

          这一课只练一件事:在有限的 里把每个可能都试一遍,并按题意做 。这就是 和 。完成四个小任务:选出范围,走一遍 1 到 3 数偶数,再对照 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s2-08/index.html b/lessons/s2-08/index.html index 217d2ec..abca294 100644 --- a/lessons/s2-08/index.html +++ b/lessons/s2-08/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先看规模,再选写法

          +

          这一课只练一件事:先用 判断程序能不能跑完。把 记成大约 n 次,把 记成大约 n×n 次,再主动准备 。完成四个小任务:选出估法,数一遍 n=3,再对照 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-01/index.html b/lessons/s3-01/index.html index b3a2ee5..195fcb5 100644 --- a/lessons/s3-01/index.html +++ b/lessons/s3-01/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          用 / 和 % 拆开数字

          +

          这一课只练一件事:用 和 拆开 。个位是 x % 10,去掉个位是 x / 10,循环直到变成 0。同一套余数想法也能求 。完成四个小任务:选出拆法,走一遍 125,再看 GCD 与 。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-02/index.html b/lessons/s3-02/index.html index 0beb221..5872036 100644 --- a/lessons/s3-02/index.html +++ b/lessons/s3-02/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,13 @@

          先写停,再问更小的自己

          +

          这一课只练一件事:把问题说成更小的同类问题,这就是 。先写 ,再写一次 。用 countdown(3) 追踪三层,把 想成一叠任务卡。建议掌握再用同样想法写 。完成四个小任务:选出拆法,走三层调用,再看边界与无限递归。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-03/index.html b/lessons/s3-03/index.html index 975f314..86c0db3 100644 --- a/lessons/s3-03/index.html +++ b/lessons/s3-03/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          有序才能对半缩小

          +

          这一课只练一件事:在已经排好序、具有 的区间里做 。先确认能不能二分,再维护 ,每次看 丢掉一半。用 [1, 3, 5, 7, 9] 找 7 走三步,再测首尾答案,并看区间不缩小会变成 。对答案二分、lower_bound 细节和三分查找是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-04/index.html b/lessons/s3-04/index.html index 4bba75e..4450e80 100644 --- a/lessons/s3-04/index.html +++ b/lessons/s3-04/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先累计再查一段

          +

          这一课只练一件事:用 把连续一段加起来。先填 sum[],再查任意 :sum[r] - sum[l - 1]。用 a = [2, 5, 1, 4] 走三步,再处理从第一个元素开始,并看 漏掉 a[l] 会怎样。多次查询时预处理大约是 ,每次查询一次减法。二维前缀和、差分数组、树状数组是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-05/index.html b/lessons/s3-05/index.html index fdeda45..cfda77a 100644 --- a/lessons/s3-05/index.html +++ b/lessons/s3-05/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          两个下标盯住一段

          +

          这一课只练一件事:用 维护一段连续区间。左边那个下标叫 left,右边那个叫 right,中间那一段就是 。先判断 成不成立,再决定移动哪一边。用 a = [1, 2, 3]、和不超过 3 走三步,并核对空窗口与满窗口。n 到 1e5 时,每个指针只往右走,大约是 ,比两层循环更能通过。单调队列、三数之和是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-06/index.html b/lessons/s3-06/index.html index 5fc897e..7d54759 100644 --- a/lessons/s3-06/index.html +++ b/lessons/s3-06/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          每次选现在看起来最好的

          +

          这一课只练一件事:先定一条 的 ,再每一步按它选。选尽可能多的不重叠活动时,常用“结束最早的先选”。建议掌握:先 ,再 。必会还包括会找 :按时长最长选会失败。最小示例:美术 [1, 4]、书法 [1, 2]、围棋 [2, 5]。n 到 1e5 时,排序大约 n log n 再扫一遍,比枚举 2^n 更能通过。哈夫曼树、最短路是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-07/index.html b/lessons/s3-07/index.html index b64861a..ca71467 100644 --- a/lessons/s3-07/index.html +++ b/lessons/s3-07/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          后放进去的先拿,还是先排队的先走

          +

          这一课只练一件事:分清 的 和 的 。必会:选对容器、完成 、说出入队出队顺序。最小示例:用栈检查 ([]);边界是空栈,以及错配 ([)]。建议掌握:队列适合一层一层往外看,这是 。n 个括号扫一遍大约 O(n),常见测试点能通过。图上的完整 BFS、最短路、单调栈是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s3-08/index.html b/lessons/s3-08/index.html index d5290cf..3a4428c 100644 --- a/lessons/s3-08/index.html +++ b/lessons/s3-08/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          用已经算好的小问题,推出更大的问题

          +

          这一课只练一件事:用已经算好的小问题推出更大的问题,这叫 (C++ 里常用数组 dp)。必会:用一句话解释 dp[i]、写出 、用手算前三步验证 。最小示例:每次走 1 或 2 级台阶,求走到第 n 级的方法数。边界是忘了初值,以及 n=1。建议掌握:把答案记在 表里。填 n 格大约 O(n),常见测试点能通过。状态压缩、二维背包、树形 DP 是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-01/index.html b/lessons/s4-01/index.html index 07bd23a..c95ecbc 100644 --- a/lessons/s4-01/index.html +++ b/lessons/s4-01/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          按题面交一份干净程序

          +

          这一课只练一件事:按竞赛题面交一份干净程序,这叫 。必会:圈出 、删掉提示语、按 独立核对。最小示例:读入整数 n,输出 n+1;范围 1 <= n <= 1000,也就是 n <= 1000;样例输入 3,输出 4。边界是多写了“请输入”,以及忘了对照样例。建议掌握:cin/cout 接到 。评测机内部和 freopen 文件读写是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-02/index.html b/lessons/s4-02/index.html index dd835db..e6d82c1 100644 --- a/lessons/s4-02/index.html +++ b/lessons/s4-02/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先填建模卡再写代码

          +

          这一课只练一件事:把题意拆成 ,再动手写程序。必会:分清 、用 、写出两个边界。最小示例:读入 n 和 n 个整数,输出它们的和; 1 <= n <= 1000;样例输入 3 和 1 2 3,输出 6,也就是 1+2+3=6。边界是 n=1,以及全是 0。建议掌握:先列 。图论、搜索树、状态压缩是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-03/index.html b/lessons/s4-03/index.html index 1ded1eb..01ceb58 100644 --- a/lessons/s4-03/index.html +++ b/lessons/s4-03/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          画状态表,追踪模拟题每一步

          +

          这一课只练一件事:先画 ,再按 逐步处理。必会:判断每一步的 、用 核对结果、写出两个边界。最小示例:存钱罐模拟,读入 n 次操作,正数表示存入,负数表示取出的金额;1 <= n <= 1000;余额不足时跳过该次取款。样例输入 3 次操作 +5 -3 -10,追踪过程 5 → 2 → 跳过,输出余额 2、跳过 1 次。边界是 n=1,以及全部因 不足被跳过。建议掌握:统计跳过次数。多账户、多币种、复杂利率是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-04/index.html b/lessons/s4-04/index.html index b6cb2dc..20aaa83 100644 --- a/lessons/s4-04/index.html +++ b/lessons/s4-04/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          画搜索树,学会选择、剪枝与回溯

          +

          这一课只练一件事:把每一步的选择画成 ,用 逐条走完。必会:判断安全的 条件、写完一条分支后正确 、写出停止搜索的边界。最小示例:从 3 个数 1、2、3 中选出若干个,使它们的和恰好等于 target = 4;每个数只能选或不选,1 <= n <= 20。样例追踪:选 1、跳过 2、选 3,和为 1 + 3 = 4,命中后记录方案并回溯,继续尝试其他组合。边界是 n=1(只判断这一个数是否等于 target),以及所有数都试完仍未凑出 target(无解,回溯到底)。建议掌握:统计一共找到几种方案。多维状态剪枝、双向搜索、记忆化是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-05/index.html b/lessons/s4-05/index.html index 3f7acdf..7c9493d 100644 --- a/lessons/s4-05/index.html +++ b/lessons/s4-05/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          排好序,再选对工具去查找和计数

          +

          这一课只练一件事:拿到一批数据后,先看清 ,再决定用、先后扫描,还是复用。必会:判断何时该用排序、处理好、按取值范围选出数组或映射方案。最小示例:6 名学生的成绩 90 78 85 90 85 90,分数范围 0 <= 分数 <= 100,1 <= n <= 1000,要求统计每个分数出现次数,并输出出现次数最多的分数。样例追踪:排序后是 78 85 85 90 90 90,扫描一遍数出 78 出现 1 次、85 出现 2 次、90 出现 3 次,最高频分数是 90。边界是 n=1(这唯一一个分数本身就是最高频,次数为 1),以及多个分数并列最高频时按“取数值更小的分数”统一规则。建议掌握:分数范围很小(0~100)时可以直接开计数数组,比排序更省事。范围变得很大(比如到 10 的 9 次方)时改用映射结构是拓展。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-06/index.html b/lessons/s4-06/index.html index ebac562..0ed9863 100644 --- a/lessons/s4-06/index.html +++ b/lessons/s4-06/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,13 @@

          选或不选,推出最优子结构

          +

          这一课只练一件事:遇到“不能选相邻两个位置、要最大化总和”这类问题时,用 dp[i] 表示到第 i 个位置为止能拿到的最大总和,用写出转移式子,并确认这是而不是靠感觉贪心。必会:能列出状态表、能写出转移式子、能检查不可达状态(比如 i-2 不存在时不能越界读取,要按已给的初值处理)。最小示例:4 个位置的金额 3 7 4 6,规则是不能选相邻两个位置,求能拿到的最大总和,1 <= n <= 1000。样例追踪:dp[0] = 3,dp[1] = max(3, 7) = 7,dp[2] = max(dp[1], dp[0]+4) = max(7, 7) = 7,dp[3] = max(dp[2], dp[1]+6) = max(7, 13) = 13,所以 dp[3] = 13。边界是 n=1(只有一个位置,直接选它)和不可达状态(i-2 越界时不能凭空访问,要靠 dp[0]、dp[1] 的初值兜底)。建议掌握:这比枚举所有选法(2 的 n 次方种)快得多,n 变大时只有枚举才会超时。拓展:如果 dp[i] 只依赖前两个状态,可以用把整个数组换成两个变量,现在只需要认识这个概念,不强制要求会写。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-07/index.html b/lessons/s4-07/index.html index 74aa244..5d30ebb 100644 --- a/lessons/s4-07/index.html +++ b/lessons/s4-07/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          用邻接表和访问标记,数出连通块

          +

          这一课只练一件事:遇到“若干个点、若干条边,问一共分成几组互相连通”这类问题时,用把点和边画出来,用记录每个点连着谁,再用防止重复访问,跑一次或数出数量。必会:能画出小图和邻接关系、能写访问标记、能说明 DFS 与 BFS 的顺序差异。最小示例:5 个点(编号 0~4),边为 (0,1) (1,2) (3,4),这是无向图,1 <= n <= 1000。样例追踪:邻接表 adj[0]={1}, adj[1]={0,2}, adj[2]={1}, adj[3]={4}, adj[4]={3};访问标记 visited 全部初始化为 false;从 0 出发 DFS 访问到 {0,1,2},再从还没访问过的 3 出发 DFS 访问到 {3,4},连通块数 = 2。边界是没有边的孤立点(自己就是一个连通块)和已经访问过的点(不能再重复计数)。建议掌握:DFS 沿一条路径尽量走到底再回头,BFS 按“一层一层”向外扩展,得到的是离起点的最短步数层级,这两种顺序本质不同。拓展:BFS 求出的层数可以直接当作“最短步数”的初步认识,现在只需要知道概念,不强制要求现在就写完整的最短路模板。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s4-08/index.html b/lessons/s4-08/index.html index b9a8001..9ac820b 100644 --- a/lessons/s4-08/index.html +++ b/lessons/s4-08/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先保底再冲刺,稳住子任务得分

          +

          这一课只练一件事:题目按数据范围拆成多个时,如何在有限时间里稳稳拿到。做法是先写一份能过小数据的,再按的顺序挑战更难的子任务;写不出最优算法时,可以先用的简单实现去覆盖数据较小的子任务。必会:能圈出每个子任务的数据范围、能先写保底方案、能安排提交前的检查时间。最小示例:一道满分 100 分的题拆成两个子任务——子任务 1(1 <= n <= 1000,30 分,暴力 O(n²) 可过)、子任务 2(1 <= n <= 100000,70 分,需要 O(n log n))。你目前只会写 O(n²) 暴力。样例追踪:先写暴力覆盖子任务 1,本地用小数据测试通过,预计至少拿到 30 分;再尝试把算法优化到 O(n log n) 冲子任务 2 的 70 分;如果优化没调完,仍然提交能跑的暴力版本,保住已经确定的 30 分。边界是不能因为一心冲满分而不写任何代码(0 分)、也不能在合并优化代码时把还能跑的保底方案改坏。建议掌握:先完成有把握、分值明确的子任务,再挑战更难的子任务,不要把全部时间押在最难的部分。拓展:复杂度降级只是拓展技巧——用更简单但正确的实现去换取小数据子任务的分数,不强制现在就会写出满分算法。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-01/index.html b/lessons/s5-01/index.html index 5fcbb71..b81123c 100644 --- a/lessons/s5-01/index.html +++ b/lessons/s5-01/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先写流程卡,限时里不慌

          +

          这一课只练一件事:在比赛时间里,按固定的从读题走到提交。顺序是、写、编码、做,并。必会:能在限时内写一张流程卡、能预留检查时间、不跳过样例。最小示例:一次 40 分钟训练只做一道题。样例追踪:第 1 到 5 分钟审题并记下时间预算;第 6 到 10 分钟写下已知、未知和打算;第 11 到 32 分钟编码;第 33 到 36 分钟对照样例;最后 4 分钟做提交前检查。边界是不能拿到题就直接敲代码,也不能样例还没对照就提交。建议掌握:编码前先留下建模草稿,哪怕只有三行。拓展:把 40 分钟再拆成更细的分钟级时间片只是拓展习惯,现在不当硬门槛。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-02/index.html b/lessons/s5-02/index.html index 2d62c9d..33d0f37 100644 --- a/lessons/s5-02/index.html +++ b/lessons/s5-02/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          按证据修错,不靠乱改

          +

          这一课只练一件事:用按证据修错,而不是靠猜测乱改。顺序是先做,再用或检验假设,最后记下修复原因并重新测试。必会:能把错误缩小到小数据;能写出验证假设的输出;不靠乱改碰运气。最小示例:求前缀和的程序对 n=5 错了,先缩成 n=2 仍错。样例追踪:n=5 输出 9,手算应是 10;缩到 a=[1,2] 仍少 1;分段输出发现 prefix 从 a[1] 开始加。边界是不要在大数据上猜改,也不要改完不回归。建议掌握:写下修复原因。拓展:用暴力程序做只是拓展习惯,现在不当硬门槛。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-03/index.html b/lessons/s5-03/index.html index aa962ef..cb9e210 100644 --- a/lessons/s5-03/index.html +++ b/lessons/s5-03/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          崩溃之前,先认出这五个陷阱

          +

          这一课只练一件事:看到一段快出错的代码,先说出它属于哪一类语言层面的失分点,再选出正确修法。五类陷阱是、、、与混淆、以及没删干净的调试输出。必会:能识别这五类陷阱;能为大乘积选择;能检查数组访问是不是在 0 到 n-1 之内。最小示例:`int total = 50000 * 50000;` 编译能过,但 total 变成负数——这是溢出,不是越界。边界是范围表和精确位数是拓展,现在只要求先看规模。建议掌握:提交前删掉之外的调试文字并再测一遍。拓展:把五类陷阱整理成赛前检查清单,只是习惯,不是新语法。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-04/index.html b/lessons/s5-04/index.html index 065f684..67409d2 100644 --- a/lessons/s5-04/index.html +++ b/lessons/s5-04/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          先写能验证的基线,再稳稳升级

          +

          这一课只练一件事:遇到一道暂时写不出满分算法的难题时,先写一份能验证的,再逐步升级,而不是一步到位地追求最优解。做法是:先用最直接的写法覆盖小数据,标注它能处理的范围和会漏掉的,再动手做,同时用确认没有引入。必会:能先写小数据可过的基线;能标注它的适用范围和已知会出错的特殊情况;能避免把破坏正确性的“捷径”当成优化。最小示例:区间求和题,q 次查询每次给出 l, r,求 a[l..r] 的和。基线写法是每次查询都从 l 循环到 r 直接累加,复杂度 O(n·q),在 n、q 都不大时能过。样例追踪:先写这份基线并用样例测试通过;标注 l > r 是空区间应输出 0、l == r 是单点区间;再引入前缀和 sum[i] = sum[i-1] + a[i],把每次查询变成 sum[r] - sum[l-1],复杂度升级为 O(n+q);升级后用基线在小数据上对拍,确认输出完全一致。边界是不能跳过基线直接想前缀和公式、也不能为了让代码“看起来更快”而删掉必要的下标或判断。建议掌握:升级前先标好基线的适用范围,升级后立刻用基线做一次对拍,而不是假设新写法一定正确。拓展:伪优化是指看起来精简了代码、实际上悄悄改变了结果的“假优化”,现在只需要能识别一个例子。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-05/index.html b/lessons/s5-05/index.html index aa8f534..238d2d3 100644 --- a/lessons/s5-05/index.html +++ b/lessons/s5-05/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          排好提交顺序,测完再交

          +

          这一课只练一件事:在一次限时的里,先把几道题的排好,再在写完或放弃每一题之后,用如实记录测过的数据和结果,并靠判断要不要换题或提前收尾。必会:能在模拟赛开始前用几分钟浏览全部题目并排出提交顺序(有把握的先做);能在完成或放弃一题后,在自测表里如实记录测试数据、结果和花费时间。最小示例:本次模拟赛有 3 道题——T1(简单,全对预计 20 分)、T2(中等,部分测试点可拿分)、T3(难题,暂时只会写暴力),总时长 120 分钟。样例追踪:先用 5 分钟浏览三题,标出把握程度,排出顺序 T1 → T2 → T3;写完 T1 后在自测表记录“样例通过、n=0 边界也测过,用时 25 分钟”;转到 T2,写完后自测表记录“样例通过,n=0 尚未测”;剩余时间不够时不硬啃 T3,先回头补测 T2 的边界。边界是不能跳过浏览直接从第一题硬写到底、也不能为了赶时间就跳过自测表不记录。建议掌握:定期查看时间记录,判断是否该换题或提前收尾,先保住已经确定的分数。拓展:某组数据没时间测完时,要在自测表里如实写清没测的原因(例如“时间不够”),供考后复盘,而不是假装测过或直接删掉那一行。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-06/index.html b/lessons/s5-06/index.html index 74e5820..43bf659 100644 --- a/lessons/s5-06/index.html +++ b/lessons/s5-06/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          分清错在哪,才知道怎么改

          +

          这一课只练一件事:模拟赛结束、看到失分之后,先做——判断这处失分属于读题错(看漏或看错题目条件)、算法错(方法本身不对)、实现错(思路对但代码写错)、(没测最小或最大等边界)还是时间错(没能在规定时间内完成)中的哪一种;再给这一类写一条;最后挑一道用得上这条改法的题一遍,验证改法是否真的有效。必会:能把每处失分分类到五种错误之一;能给每一类写一条具体、下次能照做的改法。最小示例:模拟赛的 T2 提交后只过了 40% 的测试点——重新跑一遍样例和几组边界数据后发现,样例和 n=1 都正确,但 n 取到题目允许的最大值时输出少了最后一个元素;定位到代码里数组下标从 1 开始却写成了从 0 开始,最后一个元素被漏算。这属于实现错(思路没问题,是写代码时的具体失误),改法是“写循环前先在纸上画出下标范围,和数组大小对一遍”,然后挑一道用到同类下标的题重做一次,确认这次没有再漏算。边界是不能把说不清原因的失分笼统地归为“粗心”,也不能写完分类就结束、不落到具体改法上。建议掌握:时间不够复盘完所有题时,优先给丢分最多的几道题分类、写改法。拓展:拿不准一处失分具体属于哪一类时,要如实标注“暂不确定”,留到下次复盘或请教时再确认,而不是随便选一个类别凑数。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-07/index.html b/lessons/s5-07/index.html index d8a7082..1a9ad85 100644 --- a/lessons/s5-07/index.html +++ b/lessons/s5-07/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          两次对照,才敢说习惯稳了

          +

          这一课只练一件事:用一组独立完成第二次模拟赛,再把这次的记录和第一次(S5-05)的自测表、时间记录做,看时间分配和错误有没有真的变化,最后判断哪些是已经验证过两次的,哪些还不算。必会:能用新题组独立完成一次模拟;能把这次的记录和上一次交叉检查、对照。最小示例:上一次模拟赛(S5-05)记录里,T1 用了 25 分钟且写了自测表、T2 的边界数据没测完。这一次换了三道全新的题 T4(简单)、T5(中等)、T6(难题),总时长依旧 120 分钟。做完后对照发现:T4 依旧先排了提交顺序、也写了自测表(用时 22 分钟,比上次略快),这个习惯连续两次都做到了,可以记为稳定;但 T5 的边界数据又一次没顾上测,说明“测边界”这个习惯还没有稳定,需要再验证一次。边界是不能只看这次分数就下结论,也不能只做到一次就宣布习惯已经养成。建议掌握:把两次的时间分配并排列出来比较,具体看哪道题真的变快了。拓展:如果对照后发现某个习惯这次没有做到,要如实标注“还没稳定”,安排下次再验证,而不是凑合算作已经养成或者直接放弃练习。这里不会在浏览器里运行 C++。

          diff --git a/lessons/s5-08/index.html b/lessons/s5-08/index.html index 41c842a..49b3834 100644 --- a/lessons/s5-08/index.html +++ b/lessons/s5-08/index.html @@ -991,6 +991,58 @@ background: rgba(24, 55, 47, 0.1); } + .prereq-note { + margin-top: 18px; + padding: 14px 16px; + border: 2px dashed rgba(24, 55, 47, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.62); + } + + .prereq-note > strong { + display: block; + color: var(--forest); + font-family: "Songti SC", "STSong", serif; + font-size: 1rem; + } + + .prereq-list { + margin: 8px 0 0; + padding-left: 18px; + font-size: 0.78rem; + line-height: 1.65; + } + + .prereq-list li + li { + margin-top: 5px; + } + + .prereq-list span { + display: block; + color: rgba(24, 55, 47, 0.78); + } + + .prereq-link { + color: var(--forest); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + } + + .prereq-link:hover { + color: var(--orange); + } + + .prereq-path, + .prereq-empty { + margin: 9px 0 0; + color: rgba(24, 55, 47, 0.82); + font-size: 0.76rem; + line-height: 1.6; + } + + .type-grid { display: grid; gap: 12px; @@ -1350,6 +1402,14 @@

          整理知识地图,划清 CSP-S 的边界

          +

          这一课只练一件事:把五个阶段学过的内容整理成一张,标出还不熟练的并安排一次有针对性的复习,再完成一张,最后看清 CSP-J 和更高级内容之间的边界在哪里。必会:能完成个人知识地图;能标出自己的薄弱模块;能区分 CSP-J 核心与后续拓展。最小示例:把 S1~S5 每个阶段挑 1-2 个知识点列成表格,在“熟练/一般/薄弱”里选一个,比如“递归”标记为“一般”、“DP 状态设计”标记为“薄弱”。给薄弱模块各安排一次具体复习(比如“周三晚上重做 S3-08 的三道 DP 题”),再对照赛前清单确认编译环境、常用代码片段、时间分配计划都准备好了。边界是不能只写“基础不好”这种笼统标注,也不能把 CSP-S 的更高级算法当成 CSP-J 现在就要会。建议掌握:把薄弱模块按优先级排序,先复习出现次数最多、影响面最大的那个。拓展:知道 CSP-S 大致会遇到更复杂的数据结构和算法模型,但现在只需要知道边界在哪,不必现在展开学习。这里不会在浏览器里运行 C++。

          diff --git a/package.json b/package.json index db9095d..8e8233e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "e2e:flow:s3": "CSP_E2E_LESSONS=s3-01,s3-02,s3-03,s3-04,s3-05,s3-06,s3-07,s3-08 bun tests/e2e/flow-cdp.ts", "e2e:flow:s4": "CSP_E2E_LESSONS=s4-01,s4-02,s4-03,s4-04,s4-05,s4-06,s4-07,s4-08 bun tests/e2e/flow-cdp.ts", "e2e:flow:s5": "CSP_E2E_LESSONS=s5-01,s5-02,s5-03,s5-04,s5-05,s5-06,s5-07,s5-08 bun tests/e2e/flow-cdp.ts", - "e2e:keyboard": "bun tests/e2e/keyboard-cdp.ts" + "e2e:keyboard": "bun tests/e2e/keyboard-cdp.ts", + "e2e:prereq": "bun tests/e2e/prereq-cdp.ts" } } diff --git a/scripts/curriculum.ts b/scripts/curriculum.ts index f6e4b5c..043e66e 100644 --- a/scripts/curriculum.ts +++ b/scripts/curriculum.ts @@ -1,6 +1,8 @@ export type Course = { id: string; phase: number; + // 先修课 ID(只写真正会用到的更早的课,按课程顺序排列;第一课为空数组)。 + prerequisites: string[]; title: string; duration: string; objectives: string[]; diff --git a/tests/e2e-count-facts.test.ts b/tests/e2e-count-facts.test.ts index fbfef02..302e0b7 100644 --- a/tests/e2e-count-facts.test.ts +++ b/tests/e2e-count-facts.test.ts @@ -436,10 +436,52 @@ test("键盘清单的固定项编号与脚本声明的每课项数对齐,且 }); test("三个复验脚本都自带项数自查,声明与实跑不符会当场失败", () => { - for (const document of [typedScript, singleLessonScript, keyboardScript]) { + for (const document of [typedScript, singleLessonScript, keyboardScript, prereqScript]) { expect({ document, selfCheck: read(document).includes("检查项数与声明不符") }).toEqual({ document, selfCheck: true, }); } }); + +// ── 先修关系可见与可达段 ──────────────────────────────────────────────────── +// 先修块是每课进页面第一眼看到的东西:位置、文字、链接可达、键盘可达、手机竖屏各有一项检查, +// 再加两项「检查器自己有没有空转」的自检。清单与文档里的项数绑回脚本声明,脚本加了检查而文档 +// 没跟上就会当场失败——本轮就是靠这个把「每课 5 项」改回「每课 6 项」的。 +const prereqScript = "tests/e2e/prereq-cdp.ts"; +const prereqChecklist = "tests/e2e/prereq-manual-checklist.md"; + +test("先修复验的每课项数与总项数都写进了文档", async () => { + const { loadCurriculum } = await import("../scripts/curriculum.ts"); + const lessonCount = (await loadCurriculum()).length; + const perLesson = declaration(prereqScript, "checksPerLesson"); + const selfChecks = declaration(prereqScript, "selfChecks"); + const total = lessonCount * perLesson + selfChecks; + + for (const document of [...proseDocuments, prereqChecklist]) { + const claims = claimsAfter(document, "e2e:prereq"); + + expect({ document, claimed: claims.totals.length > 0 }).toEqual({ document, claimed: true }); + for (const claim of claims.totals) expect({ document, claim }).toEqual({ document, claim: total }); + } + + const row = read(prereqChecklist) + .split("\n") + .filter((line) => line.startsWith("|") && line.includes("prereq-cdp.ts")) + .at(-1); + if (!row) throw new Error(`${prereqChecklist} 里找不到先修复验记录`); + expect(row).toContain(`${total}/${total} 项检查通过`); + expect(row).toContain(`各 ${perLesson} 项`); +}); + +test("先修清单的固定项编号与脚本声明的每课项数对齐,且没有重号", () => { + const source = read(prereqChecklist); + const perLesson = declaration(prereqScript, "checksPerLesson"); + const heading = source.match(/## 每课固定复验的\s*(\d+)\s*项/); + if (!heading) throw new Error(`${prereqChecklist} 里找不到「每课固定复验的 N 项」标题`); + expect(Number(heading[1])).toBe(perLesson); + + const section = source.slice(heading.index! + heading[0].length).split(/\n##\s/)[0]; + const numbers = [...section.matchAll(/^\s*(\d+)\.\s/gm)].map((entry) => Number(entry[1])); + expect(numbers).toEqual(Array.from({ length: perLesson }, (_, index) => index + 1)); +}); diff --git a/tests/e2e/manual-checklist.md b/tests/e2e/manual-checklist.md index 1e1728d..3c44ddd 100644 --- a/tests/e2e/manual-checklist.md +++ b/tests/e2e/manual-checklist.md @@ -22,6 +22,11 @@ (结构检查每课 8 项 + S1-02 与 S3-01 的键盘深路径每课 4 项);只想跑一课或换两课走深路径分别用 `CSP_E2E_KEYBOARD_LESSONS` 与 `CSP_E2E_KEYBOARD_DEEP` 指定,跑的命令本身不变。 +先修关系可见与可达(先修块的位置、文字、链接可达、键盘可达与手机竖屏)另有清单: +[`prereq-manual-checklist.md`](./prereq-manual-checklist.md)。`bun run e2e:prereq` 覆盖全部 40 课, +共 242 项检查(40 课各 6 项:5 项笔记本视口检查 + 1 项手机竖屏检查;另含脚本自检 2 项); +只想跑几课用 `CSP_E2E_PREREQ_LESSONS` 指定,跑的命令本身不变。 + 其余课程暂无自动化脚本,仍按下面的手工步骤执行并在报告或本节记录浏览器与日期。 ## 前置条件 @@ -46,10 +51,12 @@ bun run dev ## E2E-02 课件详情阅读 1. 选择任意一节课,例如 `S4-04 搜索与剪枝`。 -2. 阅读详情中的“学习目标”“具体内容”“检验清单”“视频组织建议”“练习建议”。 -3. 将筛选器切换为“只看未完成”,再切回“全部课件”。 +2. 阅读详情中的“先修”“学习目标”“具体内容”“检验清单”“视频组织建议”“练习建议”。 +3. 看“先修”那一栏:每条都写明课号、课名和那一课要会的一件事,点进去是同一节课。 +4. 选中 `S1-01`,确认“先修”写的是全课程第一课、不用先修,而且没有把课号硬凑成一条链接。 +5. 将筛选器切换为“只看未完成”,再切回“全部课件”。 -**预期:** 每个栏目均有可读内容;筛选改变列表而不清空已选课的内容,并显示筛选结果提示;所有状态还有文字标识,不能只靠颜色表达。 +**预期:** 每个栏目均有可读内容;“先修”排在“学习目标”之前,内容与 `index.html` 的 `courseData` 一致,链接指向真实存在的课目录;筛选改变列表而不清空已选课的内容,并显示筛选结果提示;所有状态还有文字标识,不能只靠颜色表达。 ## E2E-03 进度保存与恢复 diff --git a/tests/e2e/prereq-cdp.ts b/tests/e2e/prereq-cdp.ts new file mode 100644 index 0000000..090f354 --- /dev/null +++ b/tests/e2e/prereq-cdp.ts @@ -0,0 +1,596 @@ +#!/usr/bin/env bun +/** + * 先修关系的浏览器复验(零依赖)。 + * + * 用法: + * bun run e2e:prereq 全部四十课:40 课 × 6 项 + 2 项自检 = 242 项 + * CSP_E2E_PREREQ_LESSONS=s1-01,s3-04 bun tests/e2e/prereq-cdp.ts + * + * 与 `prerequisite-path.test.ts` 的分工:那个文件在 `bun test` 里守住静态契约(先修表合法、 + * 页面文字与 `courseData` 一致、块贴着标题且在开始按钮之前、不靠脚本生成);这个脚本在真实 + * 浏览器里验「学习者真的能用到它」——先修块一进页面就整块落在首屏内(笔记本 1280x1000 与 + * 手机竖屏 390x844 两种视口)、每条链接真的能打开对应的那一课、只用键盘也走得到并且看得见 + * 焦点环、鼠标点下去真的进得去。 + * + * 它不在 `bun test` 范围内:需要真实浏览器,属于体验验收,不是单元测试。 + * 手工清单与复验记录见 tests/e2e/prereq-manual-checklist.md。 + * + * 可用环境变量覆盖默认值: + * CSP_E2E_PORT / CSP_E2E_CDP_PORT / CSP_E2E_CHROME / CSP_E2E_ORIGIN + * CSP_E2E_PREREQ_LESSONS 只跑指定课程,逗号分隔(手机视口那一项跟着一起收窄) + */ + +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { lessonDirectoryName, loadCurriculum } from "../../scripts/curriculum.ts"; + +const projectRoot = `${import.meta.dir}/../..`; +const port = Number(Bun.env.CSP_E2E_PORT ?? 4575); +const cdpPort = Number(Bun.env.CSP_E2E_CDP_PORT ?? 9375); +const origin = Bun.env.CSP_E2E_ORIGIN ?? `http://localhost:${port}`; +const chromeCandidates = [ + Bun.env.CSP_E2E_CHROME, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/usr/bin/google-chrome", + "/usr/bin/chromium", +].filter((value): value is string => typeof value === "string" && value.length > 0); + +/** 结构检查每课固定 6 项(笔记本视口 5 项 + 手机竖屏 1 项);跑完拿它自查,声明与实跑不符就直接失败。 */ +const checksPerLesson = 6; +/** 页首那两项自检:插一条死链必须被抓到,以及检查后页面必须回到仓库里的样子。 */ +const selfChecks = 2; +/** 键盘检查最多按多少次 Tab;先修块就在页首,前面只有顶栏控件和正文里的 FAQ 按钮,走不到就说明 Tab 顺序被改了。 */ +const maxTabsToPrereq = 24; + +const curriculum = await loadCurriculum(); +const order = curriculum.map((course) => course.id); +const byId = new Map(curriculum.map((course) => [course.id, course])); + +const splitList = (value: string | undefined) => + (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +const requested = splitList(Bun.env.CSP_E2E_PREREQ_LESSONS); +for (const directory of requested) { + if (!order.map((id) => lessonDirectoryName(id)).includes(directory)) { + console.error(`不认识这节课:${directory}`); + process.exit(1); + } +} + +const allDirectories = order.map((id) => lessonDirectoryName(id)); +const selectedLessons = requested.length > 0 ? requested : allDirectories; + +type Check = { name: string; ok: boolean; detail: string }; +const checks: Check[] = []; +const servers: Bun.Subprocess[] = []; +const browsers: Bun.Subprocess[] = []; +const tempDirs: string[] = []; + +function check(name: string, ok: boolean, detail = "") { + checks.push({ name, ok, detail }); + console.log(` ${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +} + +function section(title: string) { + console.log(`\n=== ${title} ===`); +} + +async function reachable(url: string) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +async function waitFor(url: string, description: string, attempts = 120) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await reachable(url)) return; + await Bun.sleep(250); + } + throw new Error(`等待超时:${description}`); +} + +function collect(stream: ReadableStream | undefined) { + if (!stream) return; + (async () => { + for await (const chunk of stream) void chunk; + })().catch(() => {}); +} + +async function ensureDevServer() { + if (Bun.env.CSP_E2E_ORIGIN) return; + if (await reachable(`${origin}/index.html`)) return; + + console.log(`启动开发服务器:${origin}`); + const server = Bun.spawn(["bun", "scripts/dev.ts"], { + cwd: projectRoot, + env: { ...process.env, PORT: String(port) }, + stdout: "pipe", + stderr: "pipe", + }); + servers.push(server); + collect(server.stdout as ReadableStream); + collect(server.stderr as ReadableStream); + await waitFor(`${origin}/index.html`, "开发服务器"); +} + +async function ensureChrome() { + const endpoint = `http://localhost:${cdpPort}/json/version`; + if (await reachable(endpoint)) return; + + const binary = chromeCandidates.find((candidate) => existsSync(candidate)); + if (!binary) { + throw new Error(`未找到 Chrome。请安装 Chrome,或用 CSP_E2E_CHROME 指定路径(已尝试:${chromeCandidates.join("、")})。`); + } + + const profileDir = mkdtempSync(join(tmpdir(), "csp-e2e-prereq-profile-")); + tempDirs.push(profileDir); + console.log(`启动无头 Chrome:${binary}`); + const browser = Bun.spawn( + [ + binary, + "--headless=new", + "--disable-gpu", + // 固定成一台常见的笔记本:先修块必须落在首屏内,检查才有意义。 + "--window-size=1280,1000", + "--no-first-run", + "--no-default-browser-check", + `--user-data-dir=${profileDir}`, + `--remote-debugging-port=${cdpPort}`, + "about:blank", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + browsers.push(browser); + collect(browser.stdout as ReadableStream); + collect(browser.stderr as ReadableStream); + await waitFor(endpoint, "无头 Chrome 调试端口"); +} + +class Cdp { + #socket: WebSocket; + #pending = new Map void>(); + #seq = 0; + + private constructor(socket: WebSocket) { + this.#socket = socket; + socket.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (message.id && this.#pending.has(message.id)) { + this.#pending.get(message.id)!(message); + this.#pending.delete(message.id); + } + }; + } + + static async attach() { + const targets = (await (await fetch(`http://localhost:${cdpPort}/json/list`)).json()) as any[]; + const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl); + if (!page) throw new Error("没有可用的浏览器页面目标。"); + const socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error("无法连接浏览器调试通道。")); + }); + const client = new Cdp(socket); + await client.send("Runtime.enable"); + await client.send("Page.enable"); + return client; + } + + // 每次请求都必须有超时:页面正在导航时 Chrome 会把旧执行上下文里的 + // `Runtime.evaluate` 直接丢掉,回应永远不会来,没有超时就会整段挂死。 + send(method: string, params: Record = {}, timeoutMs = 10000) { + const id = ++this.#seq; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`${method} 在 ${timeoutMs}ms 内没有回应`)); + }, timeoutMs); + this.#pending.set(id, (message) => { + clearTimeout(timer); + resolve(message); + }); + this.#socket.send(JSON.stringify({ id, method, params })); + }); + } + + async evaluate(expression: string): Promise { + const message = await this.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + userGesture: true, + }); + if (message.result?.exceptionDetails) { + throw new Error(`页面抛出异常:${JSON.stringify(message.result.exceptionDetails).slice(0, 400)}`); + } + return message.result?.result?.value as T; + } + + /** 导航途中页面可能整个换掉、求值被丢掉,这里把「这一轮没读到」当成正常的等待,不当成失败。 */ + async tryEvaluate(expression: string, timeoutMs = 10000): Promise { + const message = await this.send( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true, userGesture: true }, + timeoutMs, + ).catch(() => null); + if (!message || message.result?.exceptionDetails) return null; + return message.result?.result?.value as T; + } + + async navigate(url: string) { + // 只等 readyState 不够用:同一地址再导航时旧文档当场就报 complete, + // 上一步点击引发的导航也可能还没落地,结果会读到上一页的 DOM。 + // 所以三件事同时成立才算到:文档换过(timeOrigin 不同)、加载完、地址就是请求的那一页。 + const expectedPath = new URL(url).pathname; + const previousTimeOrigin = (await this.tryEvaluate("performance.timeOrigin")) ?? 0; + await this.send("Page.navigate", { url }); + + const deadline = Date.now() + 20000; + let lastSeen = "<还没读>"; + + while (Date.now() < deadline) { + const state = await this.tryEvaluate("document.readyState", 2000); + const timeOrigin = (await this.tryEvaluate("performance.timeOrigin", 2000)) ?? 0; + lastSeen = (await this.tryEvaluate("location.pathname", 2000)) ?? "<这一轮读不到>"; + if (state === "complete" && timeOrigin !== previousTimeOrigin && lastSeen === expectedPath) return; + await Bun.sleep(50); + } + + throw new Error(`页面在 20 秒内没有停在 ${expectedPath}(最后停在 ${lastSeen}):${url}`); + } + + async press(key: string, code: string, virtualKeyCode: number, text?: string) { + await this.send("Input.dispatchKeyEvent", { + type: text ? "keyDown" : "rawKeyDown", + key, + code, + windowsVirtualKeyCode: virtualKeyCode, + nativeVirtualKeyCode: virtualKeyCode, + ...(text ? { text, unmodifiedText: text } : {}), + }); + await this.send("Input.dispatchKeyEvent", { + type: "keyUp", + key, + code, + windowsVirtualKeyCode: virtualKeyCode, + nativeVirtualKeyCode: virtualKeyCode, + }); + await Bun.sleep(20); + } + + tab() { + return this.press("Tab", "Tab", 9); + } + + /** 真实鼠标点击:先把元素滚进视野,再在它中心按下抬起,不是 DOM 里的 .click()。 */ + async clickSelector(selector: string) { + const box = JSON.parse( + await this.evaluate(`JSON.stringify((() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return null; + el.scrollIntoView({ block: "center", inline: "center" }); + const rect = el.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + })())`), + ) as { x: number; y: number } | null; + + if (!box) throw new Error(`页面上找不到可点击的元素:${selector}`); + + if (box) { + for (const type of ["mousePressed", "mouseReleased"]) { + await this.send("Input.dispatchMouseEvent", { + type, + x: box.x, + y: box.y, + button: "left", + buttons: type === "mousePressed" ? 1 : 0, + clickCount: 1, + }); + } + } + + return box; + } + + async waitForUrl(predicate: (url: string) => boolean, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const url = await this.evaluate("location.href").catch(() => null); + if (url && predicate(url)) return url; + await Bun.sleep(60); + } + + return null; + } + + close() { + this.#socket.close(); + } +} + +type BlockProbe = { + found: boolean; + visible: boolean; + viewport: string; + top: number; + bottom: number; + inFirstScreen: boolean; + text: string; + links: { href: string; text: string }[]; + anchorWarning: string | null; +}; + +type FocusState = { onPrereq: boolean; ring: boolean; tag: string; id?: string; cls?: string }; + +const readBlock = `JSON.stringify((() => { + const block = document.querySelector("aside.prereq-note"); + const viewport = \`\${innerWidth}x\${innerHeight}\`; + if (!block) return { found: false, visible: false, text: "", links: [], anchorWarning: null, viewport, top: -1, bottom: -1 }; + const rect = block.getBoundingClientRect(); + const style = getComputedStyle(block); + const lastLink = block.querySelector("a.prereq-link, .prereq-empty"); + const lastRect = lastLink ? lastLink.getBoundingClientRect() : rect; + return { + found: true, + visible: rect.height > 0 && rect.width > 0 && style.display !== "none" && style.visibility !== "hidden", + viewport, + top: Math.round(rect.top), + bottom: Math.round(lastRect.bottom), + inFirstScreen: rect.top >= 0 && lastRect.bottom <= innerHeight, + text: block.textContent.replace(/\\s+/g, " ").trim(), + links: [...block.querySelectorAll("a.prereq-link")].map((a) => ({ href: a.getAttribute("href"), text: a.textContent.replace(/\\s+/g, " ").trim() })), + anchorWarning: block.querySelector("a.prereq-link") === null && block.innerHTML.includes("href") ? "块里有链接文字但不是 a 元素" : null, + }; +})())`; + +const probeFocus = `JSON.stringify((() => { + const el = document.activeElement; + if (!el || el === document.body || el === document.documentElement) return { onPrereq: false, ring: false, tag: "body", id: "" }; + const style = getComputedStyle(el); + const ring = style.outlineStyle !== "none" && style.outlineWidth !== "0px"; + return { + onPrereq: el.classList.contains("prereq-link"), + ring, + tag: el.tagName, + id: String(el.id || ""), + cls: String(el.className || ""), + }; +})())`; + +const readBlockNow = async (cdp: Cdp) => JSON.parse(await cdp.evaluate(readBlock)) as BlockProbe; +const focusNow = async (cdp: Cdp) => JSON.parse(await cdp.evaluate(probeFocus)) as FocusState; + +const pageUrl = (directory: string) => `${origin}/lessons/${directory}/index.html`; + +function expectedText(courseId: string): string { + const course = byId.get(courseId)!; + const parts = ["先修"]; + + for (const prerequisite of course.prerequisites) { + const target = byId.get(prerequisite)!; + parts.push(`${target.id} ${target.title}`, target.objectives[0]); + } + + const index = order.indexOf(courseId); + if (index > 0) { + const previous = byId.get(order[index - 1])!; + parts.push("上一课:", `${previous.id} ${previous.title}`); + } + + return parts; +} + +try { + await ensureDevServer(); + await ensureChrome(); + const cdp = await Cdp.attach(); + + // 先做一次「检查器本身有效」的自检:页面上临时插一条指向不存在课的死链, + // 链接可达性检查必须当场指出它;随后断言页面已还原,避免把临时改动留在现场。 + section("检查器自检(临时插一条死链,必须被发现)"); + await cdp.navigate(pageUrl("s3-04")); + const beforeInjection = (await readBlockNow(cdp)).links.length; + await cdp.evaluate(`(() => { + const link = document.createElement("a"); + link.id = "e2eTempDeadLink"; + link.className = "prereq-link"; + link.setAttribute("href", "../s9-99/index.html"); + link.textContent = "临时死链"; + document.querySelector("aside.prereq-note").appendChild(link); + return true; + })()`); + + // 与逐课检查用的是同一段判定:按 href 去开发服务器上取,非 200 就算不可达。 + const injectedBroken = JSON.parse( + await cdp.evaluate(`(async () => { + const broken = []; + for (const link of document.querySelectorAll("aside.prereq-note a.prereq-link")) { + const url = new URL(link.getAttribute("href"), location.href); + const response = await fetch(url).catch(() => null); + if (!response || !response.ok) broken.push(link.getAttribute("href")); + } + return JSON.stringify(broken); + })()`), + ) as string[]; + + check( + "自检:临时插进来的死链会被链接可达性检查抓到", + injectedBroken.length === 1 && injectedBroken[0] === "../s9-99/index.html", + `判为不可达的链接:${JSON.stringify(injectedBroken)}`, + ); + + await cdp.evaluate('document.querySelector("#e2eTempDeadLink")?.remove() ?? true'); + const restored = await readBlockNow(cdp); + check( + "自检:临时改动已还原,页面回到仓库里的样子", + restored.links.length === beforeInjection, + `注入前 ${beforeInjection} 条、还原后 ${restored.links.length} 条`, + ); + + for (const directory of selectedLessons) { + const courseId = order[allDirectories.indexOf(directory)]; + const course = byId.get(courseId)!; + const previous = order.indexOf(courseId) > 0 ? byId.get(order[order.indexOf(courseId) - 1])! : null; + + section(`${course.id} ${course.title}(先修 ${course.prerequisites.join("、") || "无"})`); + await cdp.navigate(pageUrl(directory)); + const block = await readBlockNow(cdp); + + // 1) 一进页面、还没点任何按钮就能看见先修,而且还落在首屏之内(不用滚动)。 + check( + "先修块不点任何按钮就完整落在首屏内", + block.found && block.visible && block.inFirstScreen, + block.found + ? `viewport=${block.viewport} 块 ${block.top}→${block.bottom}px 文案="${block.text.slice(0, 40)}…"` + : "页面里找不到 aside.prereq-note", + ); + + // 2) 文字与 courseData 派生一致(课号、课名、要会的那件事、上一课)。 + const expected = expectedText(courseId); + const missing = expected.filter((fragment) => !block.text.includes(fragment)); + check("先修文案与 courseData 一致", missing.length === 0, missing.length === 0 ? `覆盖 ${expected.length} 段文案` : `缺:${missing.join(" / ")}`); + + // 3) 块里每条链接都能真的打开(不是死链),条数与先修 + 上一课对得上。 + const expectedLinkCount = course.prerequisites.length + (previous ? 1 : 0); + const resolutions = await Promise.all( + block.links.map(async (link) => { + const url = new URL(link.href, pageUrl(directory)); + const response = await fetch(url); + return `${link.text}->${response.status}`; + }), + ); + const brokenLinks = resolutions.filter((entry) => !entry.endsWith("->200")); + check( + "先修与上一课的链接都指向真实存在的课", + block.links.length === expectedLinkCount && brokenLinks.length === 0 && block.anchorWarning === null, + `链接 ${block.links.length}/${expectedLinkCount} 条,${brokenLinks.length === 0 ? "全部 200" : `打不开:${brokenLinks.join("、")}`}`, + ); + + // 4) 只用键盘也走得到,并且看得见焦点在哪。 + // 先修块排在页首,前面只有顶栏和组织内 FAQ 按钮,所以一路 Tab 过去必须撞上它。 + // 这一步接着用刚打开的那一页:第 1 至 3 项只读不碰焦点与滚动,此刻的页面状态 + // 正是学习者刚进来的样子(不再为了「干净」多刷新一次,少一次导航就少一次竞态)。 + let focused: FocusState = { onPrereq: false, ring: false, tag: "" }; + let tabsUsed = 0; + + for (let attempt = 0; attempt < maxTabsToPrereq; attempt += 1) { + await cdp.tab(); + focused = await focusNow(cdp); + tabsUsed += 1; + // 撞上先修链接就算走到;先撞上「开始学习」说明先修块在 Tab 顺序里被跳过了。 + if (focused.onPrereq || focused.id === "beginButton") break; + } + + if (expectedLinkCount > 0) { + check( + "Tab 走得到先修链接,且焦点环可见", + focused.onPrereq && focused.ring, + `${tabsUsed} 次 Tab 后落在 ${focused.tag}.${focused.cls ?? ""},焦点环=${focused.ring}`, + ); + } else { + check( + "第一课没有先修链接,键盘也不会撞上不存在的入口", + !focused.onPrereq, + `${tabsUsed} 次 Tab 内没有落到 .prereq-link`, + ); + } + + // 5) 真实点一下:必须落到说的那一课,而不是只写着课号。 + if (expectedLinkCount > 0) { + const targetId = course.prerequisites[0] ?? previous!.id; + const target = byId.get(targetId)!; + // 点之前先确认这条链接没有被别的元素盖住——盖住了就点不到,属于真实缺陷。 + // 无头浏览器视口只有 756x413,先像学习者那样把链接滚进视野再取坐标。 + const hit = JSON.parse( + await cdp.evaluate(`JSON.stringify((() => { + const el = document.querySelector("aside.prereq-note a.prereq-link"); + el.scrollIntoView({ block: "center", inline: "center" }); + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const top = document.elementFromPoint(x, y); + return { + x: Math.round(x), + y: Math.round(y), + covered: !(top === el || el.contains(top)), + topTag: top ? top.tagName : null, + topDesc: top ? \`\${top.tagName}.\${String(top.className || "")}\` : null, + }; + })())`), + ) as { x: number; y: number; covered: boolean; topTag: string | null; topDesc: string | null }; + + const box = await cdp.clickSelector("aside.prereq-note a.prereq-link"); + const landed = await cdp.waitForUrl((url) => url.includes(`/lessons/${targetId.toLowerCase()}/`)); + // 页面的 以「课号 ·」开头,用它确认真的落在了那一课(h1 是页面自己的标题,与目录名不同)。 + const landedTitle = landed ? await cdp.evaluate<string>("document.title") : ""; + const hrefNow = await cdp.evaluate<string>("location.pathname").catch(() => "<读不到>"); + check( + "点第一条先修链接就进到那一课,地址与课号都对得上", + landed !== null && landedTitle.startsWith(`${target.id} ·`) && !hit.covered, + landed + ? landedTitle + : hit.covered + ? `链接被 ${hit.topDesc} 盖住(点 ${box ? `${Math.round(box.x)},${Math.round(box.y)}` : `${hit.x},${hit.y}`})` + : `点击 ${hit.x},${hit.y} 后停在 ${hrefNow},期望 ${targetId}`, + ); + } else { + // 第一课没有可点的先修入口:确认块里确实没有任何链接,不会把学习者带走。 + check("第一课的先修块里没有任何链接", block.links.length === 0, `链接 ${block.links.length} 条`); + } + } + + // 最后一节换成手机竖屏再走一遍全部课:先修块同样要在首屏内。 + // 这是移动端可读性的硬指标,也是这个功能最容易悄悄退化的一处(图例、段落一长就把它挤下去)。 + section("手机竖屏(390x844)下逐课确认先修块仍在首屏内"); + await cdp.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); + + for (const directory of selectedLessons) { + const courseId = order[allDirectories.indexOf(directory)]; + await cdp.navigate(pageUrl(directory)); + const block = await readBlockNow(cdp); + check( + `${courseId} 手机竖屏下先修块在首屏内`, + block.found && block.visible && block.inFirstScreen, + block.found ? `viewport=${block.viewport} 块 ${block.top}→${block.bottom}px` : "找不到先修块", + ); + } + + await cdp.send("Emulation.clearDeviceMetricsOverride"); + + cdp.close(); +} finally { + for (const browser of browsers) browser.kill(); + for (const server of servers) server.kill(); + for (const directory of tempDirs) rmSync(directory, { recursive: true, force: true }); +} + +const failed = checks.filter((entry) => !entry.ok); +console.log(`\n${checks.length - failed.length}/${checks.length} 项通过`); + +// 声明与实跑不符时先失败,避免文档里的项数悄悄过期。 +const expectedTotal = selectedLessons.length * checksPerLesson + selfChecks; +if (checks.length !== expectedTotal) { + console.error( + `\n检查项数与声明不符:脚本声明每课 ${checksPerLesson} 项 + ${selfChecks} 项自检 = ${expectedTotal} 项,实际跑了 ${checks.length} 项。` + + `\n请同步 checksPerLesson / selfChecks、tests/e2e/prereq-manual-checklist.md、docs/roadmap.md、docs/feature-checklist.md 与 README.md 里的数字。`, + ); + process.exit(1); +} + +if (failed.length > 0) { + console.error("\n未通过:"); + for (const entry of failed) console.error(`- ${entry.name} -> ${entry.detail}`); + process.exit(1); +} + +console.log("先修关系浏览器复验全部通过。"); diff --git a/tests/e2e/prereq-manual-checklist.md b/tests/e2e/prereq-manual-checklist.md new file mode 100644 index 0000000..38fdd58 --- /dev/null +++ b/tests/e2e/prereq-manual-checklist.md @@ -0,0 +1,114 @@ +# 先修关系可见与可达手工端到端验收(全部 40 课) + +这份清单管的是「一节课的先修关系能不能在进页面的一分钟内看明白」。它和 +[`flow-manual-checklist.md`](./flow-manual-checklist.md)(整节课走不走得通)、 +[`keyboard-manual-checklist.md`](./keyboard-manual-checklist.md)(键盘走同一条路会不会撞墙)不互相替代: +那两个管「课内」,这个管「进课之前」——这节课要先会什么、上一课是哪一课、点一下能不能真的回去补。 + +零基础学习者最容易卡住的地方就在这里:从链接、收藏或老师发的地址直接落到第五阶段某一课,如果 +不知道自己缺的是「数组下标从 0 开始」还是「循环边界」,就会在课内反复受挫却找不到原因。所以每节课 +的标题下面必须有第一句就说清先修关系的家底,而不是让学习者自己往前翻。 + +先修数据只有一处来源:`index.html` 的 `courseData`(每课 `prerequisites` 数组)。课程目录页的详情面板、 +每节课页首页的静态先修块都由它派生,`tests/prerequisite-path.test.ts` 在 `bun test` 里把三处绑死: +先修表改了而页面没改、或者页面上的课名与目录名对不上,都会当场失败。 + +## 前置条件 + +在仓库根目录运行 `bun run dev`,从 <http://localhost:4173/> 进入任意一课。想要窄屏表现,可以把窗口拉窄 +到手机宽度(约 390px)再刷新。先修块的位置与内容不依赖任何操作,进页面就能看到。 + +## 每课固定复验的 6 项 + +前五项在笔记本视口(1280×1000)下进行,第六项换手机竖屏(390×844): + +1. 先修块(`aside.prereq-note`)不点任何按钮就已渲染(有尺寸、不是 `display: none`),并且**整块落在首屏 + 之内**——块的顶边不小于 0、最后一条链接(或「不用先修」那一行)的底线不超过视口高度,不需要先滚动。 + 它排在 `<h1>` 正下方、介绍段落与必会/建议掌握/拓展图例之前:这三块一长就会把它挤出首屏(S5-08 在 + 手机竖屏下曾掉到 1060px,当时它排在图例后面)。 +2. 块里的文字与 `courseData` 对得上:每条先修都写明「课号 + 课名」和「要会的那一件事」,最后一行是 + 「上一课:」加紧邻的上一课;这些文字来自静态标记,不靠脚本生成,脚本报错也照样可见。 +3. 块里每条链接都真的能打开:链接条数等于「先修课数 + 1(上一课)」,每条的 `href` 指向真实存在的 + 课程页面(第一课是 0 条)。链接文字里不许出现「像链接但其实是 `<span>`」这种点不动的写法。 +4. 只用键盘 `Tab` 也能走到先修链接,并且焦点环可见(`outline` 不是 `none`、宽度不是 0)。先修块排在页首, + 顶栏与正文里的词条按钮之后很快轮到它:脚本最多按 24 次 Tab,先撞上「开始第一个任务」就算走不到。 +5. 鼠标点第一条先修链接,真的会进入那一课:地址落在 `/lessons/<课号>/index.html`,页面 `<title>` 以 + 「课号 ·」开头(页面的 `<h1>` 是课自己的标题,与目录名不同,所以用 `<title>` 认课)。点之前先确认 + 这条链接没有被别的元素盖住——盖住就点不到。 +6. 换成手机竖屏(390×844)重新进入每一课,先修块同样整块落在首屏内。这是移动端可读性的硬指标, + 也是最容易悄悄退化的一处:图例、介绍段落或标题一长,先修块就会掉到首屏之外。 + +第一课(S1-01)没有先修:第 2 项看到的是「不用先修,会用键盘和浏览器就能开始」;第 3 项核对块里一条 +链接都没有;第 4 项核对一路 `Tab` 过去不会落到不存在的 `.prereq-link` 上;第 5 项换成核对块里确实没有 +任何链接,不会把学习者带走。 + +## 自动化复验记录 + +上面 6 项都能在真实浏览器里自动重跑一遍。想少跑几课时用环境变量改范围(手机视口那一项跟着一起收窄): + +```bash +bun run e2e:prereq # 全部 40 课,40 课 × 6 项 + 2 项自检 = 242 项检查 +CSP_E2E_PREREQ_LESSONS=s1-01,s3-04 bun tests/e2e/prereq-cdp.ts # 只跑两课,项数按课数等比缩小 +``` + +脚本自行启动开发服务器(`bun scripts/dev.ts`,默认端口 4575)与无头 Chrome(`--headless=new`, +`--window-size=1280,1000`),结束后关掉自己启动的进程并删除临时配置目录。它需要本机装有 Chrome; +换成别的可执行文件路径用 `CSP_E2E_CHROME` 指定,端口冲突时用 `CSP_E2E_PORT` / `CSP_E2E_CDP_PORT`, +要复用已在运行的服务用 `CSP_E2E_ORIGIN`。它不属于 `bun test`:断言依赖真实浏览器渲染、真实按键事件与 +真实鼠标点击,属于体验验收而不是单元测试。 + +脚本自带**两项检查器自检**:先往 S3-04 的先修块里临时插一条指向不存在课程的死链,当场确认链接可达性检查 +确实报出这一条(证明这项检查不是空转),再断言页面已还原成仓库里的样子(临时改动没有留在工作树里)。 +自检不过说明是检查器坏了,不是页面坏了。 + +| 复验日期 | 浏览器 | 脚本 | 结果 | +| --- | --- | --- | --- | +| 2026-09-21 | Chrome 153(`--headless=new`,CDP 驱动) | `tests/e2e/prereq-cdp.ts` | 242/242 项检查通过(40 课各 6 项:5 项笔记本视口检查 + 1 项手机竖屏检查;另含脚本自检 2 项),连续三次整段复验结果一致 | + +## 这一轮修掉的三处脚本缺陷与一处真实缺陷 + +先修块的排法是功能缺陷,另外三处是这个脚本自己骗人的地方——它们要么让复验「看起来通过」,要么让整段复验 +卡死,不修就不算真的验过: + +1. **点不动的链接没被发现**。无头浏览器默认视口只有 756×413,先修链接落在首屏之外,鼠标事件打在空处。 + 现在先滚动到视口中央再点,并新增一项命中测试(`elementFromPoint` 确认链接没有被别的元素盖住), + 点不动会当场失败,而不是静默通过。 +2. **读错页面**。换页只轮询 `document.readyState` 时,旧文档在新地址还没加载完就已经是 `complete` + (点击触发的跳转和同地址重进都会命中),于是 S2-07 的检查读到了 S4-05 的页面、S3-07 读到了 S4-06, + 而且可能带着错误页面的结论「通过」。现在换页必须同时满足三件事:`timeOrigin` 变了、`readyState` + 为 `complete`、`location.pathname` 就是目标路径;否则直接失败,并报出最后看到的是哪一页。 +3. **整段复验挂死在中间**。`Runtime.evaluate` 的响应在文档被替换时会被浏览器丢掉,没有超时的实现会 + 一直等下去(实测跑到第 15 课就再也不动)。请求加了 10 秒超时,轮询用 2 秒超时把「这一轮读不到」 + 当成正常等待;同时去掉每课多余的两次导航(前三项都是只读检查,复用同一个刚到达的页面即可), + 整段从「卡死」变成 25 秒跑完。 + +功能缺陷是排法本身:先修块最初排在 `<h1>`、介绍段落与「必会 / 建议掌握 / 拓展」图例之后。用真实视口量过 +之后发现这不成立: + +| 视口 | S1-01 | S2-07 | S5-08 | +| --- | --- | --- | --- | +| 笔记本 1440×900 | 473px 起 | 434px 起 | **788px 起**(末行 852px,几乎压到屏幕底部) | +| 手机竖屏 390×844 | 428px 起 | 519px 起 | **1060px 起(整块在首屏之外)** | + +原因不是先修块本身,而是它前面那块图例:三档难度的说明在窄屏下要折很多行,把先修关系一路推到屏幕外。 +修法是把先修块搬到 `<h1>` 正下方——标题一读完就是「这节课要先会什么」,段落与图例跟在后面。 +搬完之后同样的量测是: + +| 视口 | S1-01 | S2-07 | S5-08 | +| --- | --- | --- | --- | +| 笔记本 1440×900 | 319px 起 | 240px 起 | 319px 起 | +| 手机竖屏 390×844 | 212px 起 | 212px 起 | 263px 起 | + +`tests/prerequisite-path.test.ts` 现在锁住新位置(`</h1>` 与先修块之间不允许出现任何别的内容), +`bun run e2e:prereq` 的每课第 1 项与手机竖屏那一项都在量同一件事,谁把它排回图例后面都会当场失败。 + +## 仍需人工确认的部分 + +脚本能证明「位置对、文字对、点得进、键盘够得到、常见视口下都在首屏内」,下面这些还得人来看: + +- 先修块的视觉分量是否合适:虚线框、字号与正文的对比度,在深色块或高对比度设置下读起来是否仍然清楚。 +- 「先修」这个词对零基础学习者是否够直白,是否需要配一句更口语的说明(例如「学这一课之前,先会这些」)。 +- 先修图是否合理:现在的先修表是按知识依赖手写的(`S3-04 前缀和` 指向 `S1-06 循环` 与 `S2-01 数组`, + 而不是简单地指向上一课)。课与课之间的依赖调整属于课程规划判断,脚本只能保证「表与页面一致」, + 保证不了「依赖挑得对」。 +- 极窄视口(宽度小于 320px)与横屏手机下的表现,以及字体放大到 200% 时先修块是否仍完整可读。 diff --git a/tests/next-lesson.test.ts b/tests/next-lesson.test.ts index 0ef9ef6..6f90853 100644 --- a/tests/next-lesson.test.ts +++ b/tests/next-lesson.test.ts @@ -79,8 +79,18 @@ test("最后一课给出课程收尾出口,不指向不存在的下一课", as expect(page).toMatch(/<nav[^>]*id="nextStep"[^>]*hidden/); expect(renderStepsBody(page, directory)).toContain("nextStep.hidden ="); expect(page).not.toContain("data-next-lesson="); - // 没有下一课目录可指,就不能留下指向兄弟课目录的死链。 - expect(page).not.toMatch(/href="\.\.\/s\d/); + // 没有下一课目录可指。末课页面仍会回指更早的课(先修与「上一课」入口),所以这里不再拿 + // 「页面里不许出现 ../sN 链接」当替身,而是逐条核对课内链接都指向真实存在的课目录: + // 既挡住指向不存在课的死链,也不会误伤回看入口。 + const siblingDirectories = [...page.matchAll(/href="\.\.\/([a-z0-9-]+)\/index\.html"/g)].map((match) => match[1]); + + expect(siblingDirectories.length).toBeGreaterThan(0); + for (const directory of siblingDirectories) { + expect({ directory, exists: await Bun.file(`${lessonsRoot}/${directory}/index.html`).exists() }).toEqual({ + directory, + exists: true, + }); + } expect(nav.element).toContain("data-course-complete"); // 收尾出口要指向课程路线,且如实说明五阶段 40 课已经走完。 diff --git a/tests/prerequisite-path.test.ts b/tests/prerequisite-path.test.ts new file mode 100644 index 0000000..7d4086d --- /dev/null +++ b/tests/prerequisite-path.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { lessonDirectoryName, loadCurriculum } from "../scripts/curriculum"; + +// 先修关系契约。 +// +// `docs/roadmap.md` 的「完成的样子」要求「任何一节课都能在一分钟内看出它的先修关系」, +// 但这句话此前在 courseData、lesson.json 和页面里都没有对应物:学习者点进任意一课, +// 页面上只有本课任务、必会/建议掌握/拓展三档和返回课程路线,看不出「开始之前得先会什么」。 +// +// 这里把这份内容绑成一个可机读、可复算的契约: +// 1. `index.html` 的 courseData(唯一内容源)为每课登记 `prerequisites`——只写真正会被用到的 +// 更早的课,不把路径上的上一课机械照抄进去(第一课明确为空); +// 2. 每课页面在「开始第一个任务」之前就能读到先修,文字全部由 courseData 派生, +// 改一处不同步就当场失败; +// 3. 先修块是静态 HTML,不靠脚本生成——脚本坏了也照样看得见; +// 4. 首页课件详情也能读到先修,并且排在「学习目标」之前。 +const projectRoot = `${import.meta.dir}/..`; +const lessonsRoot = `${projectRoot}/lessons`; + +const curriculum = await loadCurriculum(); +const positionOf = new Map(curriculum.map((course, index) => [course.id, index])); + +function directoryOf(courseId: string): string { + return lessonDirectoryName(courseId); +} + +async function readPage(directory: string): Promise<string> { + return Bun.file(`${lessonsRoot}/${directory}/index.html`).text(); +} + +// 先修块本身:从静态标记里取出来单独看,避免断言在整页里「碰巧命中」别处文字。 +function prerequisiteBlock(page: string, directory: string): string { + const match = page.match(/<aside class="prereq-note" aria-label="先修">[\s\S]*?<\/aside>/); + + if (!match) { + throw new Error(`${directory}: 找不到先修块 aside.prereq-note`); + } + + return match[0]; +} + +test("每课的先修都指向更早、真实存在的课,第一课明确没有先修", () => { + expect(curriculum.length).toBeGreaterThan(0); + + for (const [index, course] of curriculum.entries()) { + expect(Array.isArray(course.prerequisites)).toBe(true); + + if (index === 0) { + // 第一课没有更早的课可指;写空数组,不能拿「无」凑一个占位的课 ID。 + expect({ id: course.id, prerequisites: course.prerequisites }).toEqual({ id: course.id, prerequisites: [] }); + continue; + } + + expect({ id: course.id, count: course.prerequisites.length > 0 }).toEqual({ id: course.id, count: true }); + // 一屏内能读完,也逼着内容作者挑真正要用的那几课。 + expect({ id: course.id, withinCap: course.prerequisites.length <= 3 }).toEqual({ id: course.id, withinCap: true }); + + for (const prerequisite of course.prerequisites) { + const position = positionOf.get(prerequisite); + + expect({ id: course.id, prerequisite, exists: position !== undefined }).toEqual({ + id: course.id, + prerequisite, + exists: true, + }); + expect({ id: course.id, prerequisite, earlier: (position ?? -1) < index }).toEqual({ + id: course.id, + prerequisite, + earlier: true, + }); + expect({ id: course.id, selfReference: prerequisite === course.id }).toEqual({ id: course.id, selfReference: false }); + } + + expect({ id: course.id, duplicates: new Set(course.prerequisites).size }).toEqual({ + id: course.id, + duplicates: course.prerequisites.length, + }); + } +}); + +test("先修是作者挑出来的更早的课,不是把路径上的上一课照抄一遍", () => { + // 纯顺序复制是最省事的写法,也会让这个字段变成「下一步」的镜像、失去先修的意义。 + // 现场按课的内容判断:确实需要紧邻上一课的课照写,不需要的不写。这里守住「有相当一部分 + // 课的先修不是紧邻上一课」,谁把它改成机械复制就会失败。 + const notAdjacent = curriculum.filter((course, index) => { + if (index === 0) return false; + return !course.prerequisites.includes(curriculum[index - 1].id); + }); + + expect(notAdjacent.length).toBeGreaterThanOrEqual(20); +}); + +test("每课在开始第一个任务之前就能读到先修,文字与 courseData 一致", async () => { + for (const [index, course] of curriculum.entries()) { + const directory = directoryOf(course.id); + const page = await readPage(directory); + const block = prerequisiteBlock(page, directory); + + // 先修要出现在「开始第一个任务」之前:学习者是在开始前决定要不要回去补课。 + expect({ directory, beforeBegin: page.indexOf(block) < page.indexOf('id="beginButton"') }).toEqual({ + directory, + beforeBegin: true, + }); + + // 而且要贴着标题、排在介绍段落与难度图例之前:先修是「一进页面一分钟内要看到」的信息, + // 排在长图例后面,手机竖屏下会掉到首屏之外(390x844 实测过 S5-08 掉到 1060px)。 + const headingEnd = page.indexOf("</h1>") + "</h1>".length; + const betweenHeadingAndBlock = page.slice(headingEnd, page.indexOf(block)); + expect({ directory, rightUnderHeading: betweenHeadingAndBlock.trim() }).toEqual({ + directory, + rightUnderHeading: "", + }); + + // 先修块是静态标记,不靠脚本生成:脚本坏了、控制台报错也照样看得见。 + const script = page.slice(page.indexOf("<script>")); + expect({ directory, scriptMentionsPrereq: script.includes("prereq") }).toEqual({ + directory, + scriptMentionsPrereq: false, + }); + + let expectedLinks = 0; + + for (const prerequisite of course.prerequisites) { + const target = curriculum[positionOf.get(prerequisite) ?? -1]; + const targetDirectory = directoryOf(target.id); + + // 每一课先修都要能一眼看出「是哪一课、讲什么、要会哪件事」,并且能直接点进去。 + expect(block).toContain(`<a class="prereq-link" href="../${targetDirectory}/index.html">${target.id} ${target.title}</a>`); + expect(block).toContain(`<span>${target.objectives[0]}</span>`); + expectedLinks += 1; + } + + // 路径上的上一课单独给出:它是导航,不是先修,两者写清楚才不会让人以为「上一课就一定先修」。 + if (index > 0) { + const previous = curriculum[index - 1]; + expect(block).toContain( + `上一课:<a class="prereq-link" href="../${directoryOf(previous.id)}/index.html">${previous.id} ${previous.title}</a>`, + ); + expectedLinks += 1; + } + + expect({ directory, links: block.match(/class="prereq-link"/g)?.length ?? 0 }).toEqual({ directory, links: expectedLinks }); + + // 先修块的入口必须是能读出来的链接,不能只写课号让人自己去目录里找。 + expect({ directory, hasList: block.includes('<ul class="prereq-list">') }).toEqual({ + directory, + hasList: course.prerequisites.length > 0, + }); + } +}); + +test("第一课的先修块说明这是全课程第一课,且不给上一课链接", async () => { + const first = curriculum[0]; + const page = await readPage(directoryOf(first.id)); + const block = prerequisiteBlock(page, directoryOf(first.id)); + + expect(block).toContain("不用先修"); + expect(block).toContain('class="prereq-empty"'); + expect(block).not.toContain("上一课"); + expect(block).not.toContain("class=\"prereq-link\""); +}); + +test("首页课件详情也给出先修,并排在「学习目标」之前", () => { + const source = readFileSync(`${projectRoot}/index.html`, "utf8"); + + const prereqSection = source.indexOf('id="prerequisiteTitle"'); + const objectiveSection = source.indexOf('id="objectiveTitle"'); + + expect(prereqSection).toBeGreaterThan(-1); + expect(objectiveSection).toBeGreaterThan(-1); + expect(prereqSection).toBeLessThan(objectiveSection); + expect(source).toContain('<ul id="prerequisiteList"'); + // 详情里的先修同样从 courseData 派生,不另写一份会过期的文案。 + expect(source).toContain("lesson.prerequisites"); + expect(source).toContain('document.querySelector("#prerequisiteList")'); + + const render = source.slice( + source.indexOf('document.querySelector("#prerequisiteList")'), + source.indexOf('document.querySelector("#objectiveList")'), + ); + // 课名与「要会的那一件事」都得从 courseData 取;在这里另抄一份文案就会失败。 + expect(render).toContain("courseData.find((item) => item.id === id)"); + expect(render).toContain("${item.id} ${item.title}"); + expect(render).toContain("${item.objectives[0]}"); + expect(render).toContain("<li>这是全课程第一课:不用先修"); +});