blendtutor_core/site/
mod.rs

1//! Static-site assembly: turn a course into a browser-deployable lesson site.
2//!
3//! `core::site` owns assembly *only* (ADR-0008, §4.1): it decides which files a
4//! site contains and their contents, and commits them to disk. It does **not**
5//! call LLMs or execute learner code — that happens in the browser at learner
6//! time, via the runtime the emitted assets boot (webR / Pyodide).
7//!
8//! The split (§2.1, §2.3): [`plan_site`] is pure — given the loaded lessons and a
9//! [`BuildTarget`] it returns the [`SiteFiles`] (relative paths + contents), with
10//! no filesystem touch, so a whole site is snapshot-testable. [`write_site`] is
11//! the single effectful step. The [`SiteLesson`] JSON is the contract between Rust
12//! (author side) and the JS runtime (learner side): a Rust-side change that keeps
13//! the JSON shape leaves the runtime untouched (§3.2).
14
15mod pyodide;
16mod webr;
17
18use std::error::Error;
19use std::fmt;
20use std::path::{Path, PathBuf};
21
22use base64::Engine;
23use rand_core::{CryptoRng, RngCore};
24use serde::{Deserialize, Serialize};
25
26use crate::course::{LessonSlug, SiteConfig};
27use crate::crypto;
28use crate::lesson::{Language, Lesson};
29
30/// Which browser runtime a built site targets, and so which language it serves.
31///
32/// An enum, not a string (§1.2): an unknown target is rejected at the CLI parse
33/// boundary rather than carried as data. Each target serves exactly one language
34/// (§3.4); the build refuses a course whose lessons do not match it (§1.3.1).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum BuildTarget {
37    /// webR — runs R in the browser, for R lessons.
38    Webr,
39    /// Pyodide — runs Python in the browser, for Python lessons.
40    Pyodide,
41}
42
43impl BuildTarget {
44    /// The language this target serves: webR runs R, Pyodide runs Python.
45    pub fn language(self) -> Language {
46        match self {
47            BuildTarget::Webr => Language::R,
48            BuildTarget::Pyodide => Language::Python,
49        }
50    }
51}
52
53impl fmt::Display for BuildTarget {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(match self {
56            BuildTarget::Webr => "webr",
57            BuildTarget::Pyodide => "pyodide",
58        })
59    }
60}
61
62/// Whether a built site's lessons carry eval validation, and at what accuracy.
63///
64/// A *represented* state (§1.2), never a missing-file inference: the effectful
65/// shell decides whether a report is present and hands the pure assembly an
66/// explicit value, so the eval-results page can always distinguish "validated at
67/// N%" from "not validated". A missing report can never masquerade as a real 0%.
68#[derive(Debug, Clone, PartialEq)]
69pub enum EvalSummary {
70    /// Evals were run; `accuracy` is the figure the Slice-13 report recorded,
71    /// carried through as-is (§3.2) and only formatted — never recomputed here.
72    Validated {
73        /// The suite's accuracy as recorded, a fraction in `[0.0, 1.0]`.
74        accuracy: f64,
75    },
76    /// No eval report accompanied the course — the page says so explicitly.
77    NotValidated,
78}
79
80impl EvalSummary {
81    /// The `data-eval-status` body attribute the *validated* page carries. The
82    /// single source for the marker, so the page and any probe asserting it cannot
83    /// drift; mutually exclusive — as a substring — with
84    /// [`Self::NOT_VALIDATED_MARKER`], so asserting one present and the other
85    /// absent pins the state unambiguously.
86    pub const VALIDATED_MARKER: &'static str = r#"data-eval-status="validated""#;
87    /// The `data-eval-status` body attribute the *not-validated* page carries — the
88    /// observable form of the [`EvalSummary::NotValidated`] state (§1.2).
89    pub const NOT_VALIDATED_MARKER: &'static str = r#"data-eval-status="not-validated""#;
90}
91
92/// One lesson as the in-browser runner consumes it: the Rust↔JS JSON contract
93/// (ADR-0008, §3.2).
94///
95/// Deliberately *not* the internal [`Lesson`]: it carries only what the runtime
96/// needs and drops `llm_evaluation_prompt` (a server/CLI concern never shipped to
97/// the client). A Rust-side change invisible to this shape is invisible to JS.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct SiteLesson {
100    /// The lesson's course-scoped slug (its manifest id).
101    pub id: String,
102    /// The lesson's human-readable title (its `lesson_name`).
103    pub title: String,
104    /// What the learner is asked to do.
105    pub prompt: String,
106    /// Optional starter code for the editor.
107    pub code_template: Option<String>,
108    /// The check code-strings, run against a submission in the browser to grade it.
109    pub checks: Vec<String>,
110    /// Third-party packages the lesson's code depends on. Always serialized as
111    /// an array — empty when the lesson declares none, never `null` or omitted
112    /// — so the JS runtime contract shape stays stable (ADR-0011, mirroring the
113    /// `solution: null` precedent from ADR-0008).
114    pub packages: Vec<String>,
115    /// The author's known-correct answer, for the runner to self-verify.
116    pub solution: Option<String>,
117    /// Optional learner-facing hints, rendered as an expandable `<details>` panel
118    /// in the browser. Always serialized (null when absent, never dropped via
119    /// `skip_serializing_if`) so the contract shape stays stable — mirroring the
120    /// `solution: null` precedent from ADR-0008.
121    pub hints: Option<String>,
122    /// Optional learner-facing gotchas (common pitfalls), rendered as an
123    /// expandable `<details>` panel in the browser. Always serialized (null
124    /// when absent, never dropped via `skip_serializing_if`) so the contract
125    /// shape stays stable — mirroring the `solution: null` and `hints: null`
126    /// precedents from ADR-0008.
127    pub gotchas: Option<String>,
128    /// Optional author rubric, added to the in-browser LLM feedback prompt
129    /// between the task and the submission (ADR-0020). Always serialized (null
130    /// when absent) like `solution`, `hints`, and `gotchas`.
131    pub success_criteria: Option<String>,
132}
133
134impl SiteLesson {
135    /// Derive the contract row from a discovered lesson and its course slug.
136    ///
137    /// Pure (§2.2): a projection of the already-validated [`Lesson`] that keeps
138    /// only the fields the browser needs. Dropping `llm_evaluation_prompt` here is
139    /// what decouples the JS runtime from the internal schema (§3.2).
140    fn from_lesson(slug: &LessonSlug, lesson: &Lesson) -> SiteLesson {
141        SiteLesson {
142            id: slug.to_string(),
143            title: lesson.lesson_name.to_string(),
144            prompt: lesson.exercise.prompt.clone(),
145            code_template: lesson.exercise.code_template.clone(),
146            checks: lesson.checks.clone(),
147            packages: lesson.packages.clone(),
148            solution: lesson.exercise.solution.clone(),
149            hints: lesson.exercise.hints.clone(),
150            gotchas: lesson.exercise.gotchas.clone(),
151            success_criteria: lesson.exercise.success_criteria.clone(),
152        }
153    }
154}
155
156/// One file in a planned site: a path relative to the output root and its
157/// contents — the unit [`write_site`] commits to disk.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct SiteFile {
160    /// Path relative to the output directory (e.g. `index.html`,
161    /// `lessons/0.json`).
162    pub path: PathBuf,
163    /// The file's full contents.
164    pub contents: String,
165}
166
167/// A fully planned site: every file it comprises, in a deterministic order.
168///
169/// Produced by the pure [`plan_site`] and consumed by the effectful
170/// [`write_site`], so the whole site can be asserted without touching a
171/// filesystem (§2.3).
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct SiteFiles {
174    files: Vec<SiteFile>,
175}
176
177impl SiteFiles {
178    /// The planned files, in deterministic order.
179    pub fn files(&self) -> &[SiteFile] {
180        &self.files
181    }
182}
183
184/// Why a site could not be planned.
185#[derive(Debug)]
186pub enum PlanError {
187    /// A lesson's language does not match the target's (e.g. an R lesson built for
188    /// the Pyodide/Python target). Refused before any file is produced (§1.3.1) so
189    /// a broken, mixed-language site never ships.
190    LanguageMismatch {
191        /// The offending lesson's course slug.
192        lesson: LessonSlug,
193        /// The language that lesson is authored in.
194        lesson_language: Language,
195        /// The target the build was asked for.
196        target: BuildTarget,
197    },
198}
199
200impl fmt::Display for PlanError {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            PlanError::LanguageMismatch {
204                lesson,
205                lesson_language,
206                target,
207            } => write!(
208                f,
209                "lesson {lesson} is written in {lesson_language:?}, which does not match \
210                 the {target} target's language ({:?})",
211                target.language()
212            ),
213        }
214    }
215}
216
217impl Error for PlanError {}
218
219/// A course's bundled eval report could not be read as the Slice-13 JSON artifact.
220///
221/// Distinct from an *absent* report (which the CLI shell maps to
222/// [`EvalSummary::NotValidated`] before parsing): a report that is present but
223/// unreadable fails the build loudly rather than silently dropping to
224/// not-validated, so a corrupt artifact can never quietly unvalidate a course.
225#[derive(Debug)]
226pub enum EvalReportError {
227    /// The report is not valid JSON, or lacks the expected `accuracy` shape.
228    Malformed(serde_json::Error),
229    /// The report parsed but its accuracy lies outside the representable
230    /// `[0.0, 1.0]` (§1.3.1): a hand-edited or corrupt figure that would otherwise
231    /// render nonsense like `200%`. Rejected at the read boundary, not carried.
232    AccuracyOutOfRange {
233        /// The out-of-range figure the report recorded.
234        accuracy: f64,
235    },
236}
237
238impl fmt::Display for EvalReportError {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            EvalReportError::Malformed(err) => {
242                write!(f, "eval report is not valid Slice-13 JSON: {err}")
243            }
244            EvalReportError::AccuracyOutOfRange { accuracy } => write!(
245                f,
246                "eval report accuracy {accuracy} is outside the expected range [0.0, 1.0]"
247            ),
248        }
249    }
250}
251
252impl Error for EvalReportError {
253    fn source(&self) -> Option<&(dyn Error + 'static)> {
254        match self {
255            EvalReportError::Malformed(err) => Some(err),
256            EvalReportError::AccuracyOutOfRange { .. } => None,
257        }
258    }
259}
260
261/// The in-browser runner core every target's `lesson-runner.js` imports: the
262/// cross-target scaffolding (lesson loading, rendering, pass/fail reporting) that
263/// a target's thin adapter sits atop. Shared, not duplicated per target (§4.2).
264const LESSON_RUNNER_CORE_JS: &str = include_str!(concat!(
265    env!("CARGO_MANIFEST_DIR"),
266    "/assets/shared/lesson-runner-core.js"
267));
268/// The COOP/COEP service-worker shim, so cross-origin isolation (and so
269/// SharedArrayBuffer) works on GitHub Pages. Vendored once and shared by every
270/// target — the isolation it provides is runtime-agnostic.
271const COI_SERVICEWORKER_JS: &str = include_str!(concat!(
272    env!("CARGO_MANIFEST_DIR"),
273    "/assets/shared/coi-serviceworker.js"
274));
275/// The BYOK feedback backend: the `FeedbackBackend` JS contract + its
276/// `byok-anthropic` impl (Slice 18). Shared by every target — the Anthropic
277/// browser call is identical whether the lesson runs in webR or Pyodide, so it is
278/// assembled once here rather than forked per target (§4.2). The seam a future
279/// backend (WebLLM) plugs into with no change to lesson rendering or execution.
280const FEEDBACK_JS: &str = include_str!(concat!(
281    env!("CARGO_MANIFEST_DIR"),
282    "/assets/shared/feedback.js"
283));
284/// The shared design-token stylesheet: the `--bt-` custom-property system and
285/// semantic region rules embedded at compile time and loaded by both targets'
286/// page shells via `<link rel="stylesheet" href="styles.css">`. Shared, not
287/// duplicated per target (§4.2): a single source of truth for the cross-target
288/// visual vocabulary, so a token-value change touches one `:root` declaration
289/// and both targets pick it up without touching Rust or JS.
290const STYLES_CSS: &str = include_str!(concat!(
291    env!("CARGO_MANIFEST_DIR"),
292    "/assets/shared/styles.css"
293));
294/// The vendored CodeMirror 6 ESM bundle: a pre-built (one-time esbuild authoring,
295/// NOT build-time codegen — ADR-0008) bundle of the CM6 core (`EditorView`) plus
296/// the R and Python language packs and the UX-polish extensions (`lineNumbers`,
297/// `highlightActiveLine`, `bracketMatching`, `indentWithTab`). Shared by every
298/// target — the editor is runtime-agnostic, so the bundle is assembled once here
299/// rather than forked per target (§4.2). Main-thread-only (no web workers) to
300/// avoid COOP/COEP conflicts; the seam AC-2/AC-3 consume read-only.
301const CODEMIRROR_JS: &str = include_str!(concat!(
302    env!("CARGO_MANIFEST_DIR"),
303    "/assets/shared/codemirror.js"
304));
305
306/// A build target's own client assets: its page shell and its runner adapter.
307///
308/// A named-field struct, not two positional `&str`s (§1.4): the shell and the
309/// runner are both HTML/JS strings of the same type, so a caller could silently
310/// transpose them — injecting runner JS where the page shell belongs — with no
311/// compile error. Naming the fields makes that illegal state unrepresentable at
312/// the one seam every target extends through.
313pub(super) struct TargetAssets<'a> {
314    /// The target's `index.html` page shell.
315    pub index_html: &'a str,
316    /// The target's `lesson-runner.js` runtime adapter.
317    pub lesson_runner_js: &'a str,
318}
319
320/// Render the `config.js` contract file: the `window.__btConfig` global the
321/// feedback.js rate limiter reads. Pure (§2.2): a direct projection of the
322/// [`SiteConfig`] into the JS contract shape — the Rust→JS boundary for
323/// site-level configuration (§3.2), alongside the per-lesson JSON contract.
324fn config_js(site_config: &SiteConfig) -> String {
325    format!(
326        "window.__btConfig = {{ maxFeedbackPerSession: {} }};",
327        site_config.max_feedback_per_session
328    )
329}
330
331/// Assemble a site from one target's [`TargetAssets`] and the loaded lessons.
332///
333/// The shared scaffolding both targets reuse (§4.2): a target supplies only its
334/// own shell and runner; the runner core, the COOP/COEP shim, the `config.js`
335/// contract, and the per-lesson JSON contract — keyed by position, never by
336/// slug, so an author's slug can never reach the filesystem as a path — are
337/// identical across targets and laid out here in deterministic order. The slug
338/// rides inside the JSON as `id`; `lessons.json` is the ordered slug index the
339/// runner enumerates. `config.js` sits immediately before `feedback.js` so the
340/// `window.__btConfig` global is available when the rate limiter reads it.
341fn assemble(
342    assets: TargetAssets<'_>,
343    lessons: &[(LessonSlug, Lesson)],
344    site_config: &SiteConfig,
345) -> SiteFiles {
346    let config_contents = config_js(site_config);
347    let mut files = vec![
348        asset("index.html", assets.index_html),
349        asset("lesson-runner.js", assets.lesson_runner_js),
350        asset("lesson-runner-core.js", LESSON_RUNNER_CORE_JS),
351        asset("coi-serviceworker.js", COI_SERVICEWORKER_JS),
352        asset("config.js", &config_contents),
353        asset("feedback.js", FEEDBACK_JS),
354        asset("styles.css", STYLES_CSS),
355        asset("codemirror.js", CODEMIRROR_JS),
356    ];
357
358    let mut slugs = Vec::new();
359    for (index, (slug, lesson)) in lessons.iter().enumerate() {
360        let site_lesson = SiteLesson::from_lesson(slug, lesson);
361        files.push(SiteFile {
362            path: format!("lessons/{index}.json").into(),
363            contents: to_json(&site_lesson),
364        });
365        slugs.push(site_lesson.id);
366    }
367    files.push(SiteFile {
368        path: "lessons.json".into(),
369        contents: to_json(&slugs),
370    });
371
372    SiteFiles { files }
373}
374
375/// A static asset file copied verbatim into the site.
376fn asset(path: &str, contents: &str) -> SiteFile {
377    SiteFile {
378        path: path.into(),
379        contents: contents.to_string(),
380    }
381}
382
383/// Serialize a contract value to pretty JSON. Infallible for the plain-data
384/// contract types, so a failure is a programmer error, not a runtime condition.
385fn to_json<T: Serialize>(value: &T) -> String {
386    serde_json::to_string_pretty(value).expect("the site contract serializes infallibly")
387}
388
389/// Fold a Slice-13 eval report's JSON into the page model, taking its accuracy
390/// as-is (§3.2) — the figure the `eval` command recorded, never recomputed here.
391///
392/// Pure (§2.3): the effectful read of the artifact file happens at the CLI edge,
393/// which hands this the bytes. An *absent* report is the shell's concern (it maps
394/// to [`EvalSummary::NotValidated`] without calling this); a *present* report that
395/// does not parse is an [`EvalReportError`], so the build fails loudly rather than
396/// silently treating a corrupt report as unvalidated.
397pub fn eval_summary_from_report_json(json: &str) -> Result<EvalSummary, EvalReportError> {
398    /// The slice of the report contract the page consumes: its aggregate accuracy.
399    /// Per-case results are intentionally ignored — the page shows the headline
400    /// figure, and reading only `accuracy` keeps the build from depending on the
401    /// full case shape.
402    #[derive(Deserialize)]
403    struct ReportArtifact {
404        accuracy: f64,
405    }
406
407    let artifact: ReportArtifact =
408        serde_json::from_str(json).map_err(EvalReportError::Malformed)?;
409    // A genuine Slice-13 report's accuracy is matched/total, always in [0.0, 1.0].
410    // Anything else is a corrupt or hand-edited artifact — reject it at the read
411    // boundary (§1.3.1) so a nonsense figure (`200%`, `-50%`, NaN) never renders.
412    if !(0.0..=1.0).contains(&artifact.accuracy) {
413        return Err(EvalReportError::AccuracyOutOfRange {
414            accuracy: artifact.accuracy,
415        });
416    }
417    Ok(EvalSummary::Validated {
418        accuracy: artifact.accuracy,
419    })
420}
421
422/// The Content-Security-Policy for every built site — the single source of
423/// truth (§1.3.1, §3.2). Both target shells hardcode this same string in their
424/// `<meta http-equiv="Content-Security-Policy">` tag, and [`eval_results_html`]
425/// references this const directly. The test
426/// `plan_site_emits_csp_sri_and_referrer_policy` is the boundary guard that
427/// pins the shells to the const — a drift is a test failure, not a runtime
428/// error.
429///
430/// - `script-src` allows `'unsafe-eval'` + `'wasm-unsafe-eval'` (webR/Pyodide
431///   need `eval` and WASM), both CDN origins, but NO wildcards (`https:`, `*`,
432///   `'unsafe-inline'`).
433/// - `style-src` allows `'unsafe-inline'` because CM6's `StyleModule` injects
434///   `<style>` tags at runtime.
435/// - `connect-src` includes both LLM providers (Fireworks + Anthropic) for
436///   BYOK feedback, and `repo.r-wasm.org` for webR package installs.
437/// - `worker-src` allows `blob:` for webR's channel workers.
438const CSP_POLICY: &str = "\
439default-src 'self'; \
440script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' \
441https://cdn.jsdelivr.net https://webr.r-wasm.org; \
442style-src 'self' 'unsafe-inline'; \
443connect-src 'self' https://cdn.jsdelivr.net https://webr.r-wasm.org \
444https://repo.r-wasm.org https://api.fireworks.ai https://api.anthropic.com; \
445img-src 'self' data: blob:; \
446worker-src 'self' blob:; \
447frame-src 'none'; \
448object-src 'none'; \
449base-uri 'self';";
450
451/// Render the standalone eval-results page for a site's [`EvalSummary`].
452///
453/// §5.1: the single function mapping the validation state to its page. Each state
454/// renders its own `data-eval-status` marker and human copy, so a learner — and
455/// the build's probe — can always tell a validated course (with its accuracy)
456/// from an unvalidated one (§1.2); a missing report never renders as a real 0%.
457fn eval_results_html(summary: &EvalSummary) -> String {
458    let (status, body) = match summary {
459        EvalSummary::Validated { accuracy } => (
460            EvalSummary::VALIDATED_MARKER,
461            format!(
462                "These lessons were validated against an eval suite at \
463                 <strong>{}% accuracy</strong>.",
464                accuracy_percent(*accuracy)
465            ),
466        ),
467        EvalSummary::NotValidated => (
468            EvalSummary::NOT_VALIDATED_MARKER,
469            "These lessons have <strong>not been validated</strong> — no eval \
470             report accompanied this course."
471                .to_string(),
472        ),
473    };
474    format!(
475        "<!doctype html>\n\
476         <html lang=\"en\">\n\
477         <head>\n\
478         <meta charset=\"utf-8\">\n\
479         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
480         <meta http-equiv=\"Content-Security-Policy\" content=\"{csp_policy}\">\n\
481         <meta name=\"referrer\" content=\"no-referrer\">\n\
482         <title>Eval results</title>\n\
483         </head>\n\
484         <body {status}>\n\
485         <h1>Eval results</h1>\n\
486         <p>{body}</p>\n\
487         </body>\n\
488         </html>\n",
489        csp_policy = CSP_POLICY,
490    )
491}
492
493/// The accuracy as a whole-number percentage for display. Reads the report's
494/// figure as-is (§3.2) and only formats it — no re-scoring.
495fn accuracy_percent(accuracy: f64) -> i64 {
496    (accuracy * 100.0).round() as i64
497}
498
499/// Plan the static site for `lessons`, targeting `target`, folding in the eval
500/// validation `eval` carries.
501///
502/// Pure (§2.1): it decides which files exist and their contents — embedded assets
503/// plus the per-lesson JSON contract — with no filesystem access, so the result is
504/// snapshot-testable. Refuses a language/target mismatch before producing anything
505/// (§1.3.1): every lesson must be in the target's language, or the whole build
506/// fails with [`PlanError::LanguageMismatch`] and no [`SiteFiles`] are returned.
507/// Past that guard the build dispatches on the [`BuildTarget`] seam (§3.4) — each
508/// target contributing only its own shell + runner atop the shared `assemble`.
509///
510/// The eval-results page is target-independent, so it is folded in here once
511/// (§4.1) rather than per target: whichever runtime a site serves, the same
512/// [`EvalSummary`] renders the same page — its accuracy, or an explicit
513/// not-validated state — so "validation travels with the lessons".
514pub fn plan_site(
515    lessons: &[(LessonSlug, Lesson)],
516    target: BuildTarget,
517    eval: &EvalSummary,
518    site_config: &SiteConfig,
519) -> Result<SiteFiles, PlanError> {
520    for (slug, lesson) in lessons {
521        if lesson.language != target.language() {
522            return Err(PlanError::LanguageMismatch {
523                lesson: slug.clone(),
524                lesson_language: lesson.language.clone(),
525                target,
526            });
527        }
528    }
529    let mut site = match target {
530        BuildTarget::Webr => webr::plan(lessons, site_config),
531        BuildTarget::Pyodide => pyodide::plan(lessons, site_config),
532    };
533    site.files.push(SiteFile {
534        path: "eval-results.html".into(),
535        contents: eval_results_html(eval),
536    });
537    Ok(site)
538}
539
540/// Write a planned site to `out_dir`, creating parent directories as needed.
541///
542/// The single effectful step (§2.3): everything about *what* the site contains was
543/// already decided by the pure [`plan_site`]; this only commits it to disk. Called
544/// only after `plan_site` succeeds, so a refused build (a language mismatch) never
545/// reaches here and never creates `out_dir`.
546pub fn write_site(out_dir: &Path, site: &SiteFiles) -> std::io::Result<()> {
547    for file in &site.files {
548        let dest = out_dir.join(&file.path);
549        if let Some(parent) = dest.parent() {
550            std::fs::create_dir_all(parent)?;
551        }
552        std::fs::write(&dest, &file.contents)?;
553    }
554    Ok(())
555}
556
557/// The decrypt shell template — a static asset embedded at compile time (§4.1).
558/// Placeholders `{{payload}}`, `{{salt}}`, `{{iv}}`, `{{iterations}}` are filled
559/// in by [`render_decrypt_shell`]. The shell provides a password input, a decrypt
560/// button, an error element, and inline WebCrypto JS that derives a key via
561/// PBKDF2, decrypts the original page via AES-GCM, injects it into the DOM, and
562/// monkeypatches `fetch` for transparent lesson-JSON decryption.
563const DECRYPT_SHELL_HTML: &str = include_str!(concat!(
564    env!("CARGO_MANIFEST_DIR"),
565    "/assets/shared/decrypt-shell.html"
566));
567
568/// Render the decrypt shell by filling in the placeholders with the encrypted
569/// payload's base64-encoded values (§5.1 — one thing: format the shell).
570fn render_decrypt_shell(payload: &crypto::EncryptedPayload) -> String {
571    DECRYPT_SHELL_HTML
572        .replace(
573            "{{payload}}",
574            &base64::engine::general_purpose::STANDARD.encode(&payload.ciphertext),
575        )
576        .replace(
577            "{{salt}}",
578            &base64::engine::general_purpose::STANDARD.encode(payload.salt),
579        )
580        .replace(
581            "{{iv}}",
582            &base64::engine::general_purpose::STANDARD.encode(payload.nonce),
583        )
584        .replace("{{iterations}}", &crypto::PBKDF2_ITERATIONS.to_string())
585}
586
587/// Whether a planned file is a content file that should be encrypted (vs an
588/// infrastructure file passed through unchanged). Content files: `index.html`,
589/// `eval-results.html`, `lessons/*.json`, `lessons.json`. Infrastructure files:
590/// `lesson-runner.js`, `lesson-runner-core.js`, `coi-serviceworker.js`,
591/// `config.js`, `feedback.js`, `styles.css`, `codemirror.js`.
592fn is_content_file(path: &str) -> bool {
593    path == "index.html"
594        || path == "eval-results.html"
595        || path == "lessons.json"
596        || (path.starts_with("lessons/") && path.ends_with(".json"))
597}
598
599/// An API key embedded in an encrypted site payload (§1.2 — a represented
600/// state, not a loose string). When present, the key rides inside the
601/// AES-256-GCM-encrypted `index.html` payload as a JSON object
602/// `{"html":"...","embeddedKey":{"provider":"...","key":"..."}}`. The decrypt
603/// shell extracts it and sets `window.__btEmbeddedKey` before the page
604/// renders, so `feedback.js`'s `applyEmbeddedKey` can pre-load it into
605/// sessionStorage — skipping the key-entry prompt.
606///
607/// The key is never baked into any shipped file as plaintext: it is inside
608/// the ciphertext, and the base64 alphabet (`A-Za-z0-9+/=`) excludes `_` and
609/// `-`, so the `fw_` / `sk-ant-` prefixes can never appear in the encoded
610/// output.
611#[derive(Debug, Clone, Serialize)]
612pub struct EmbeddedKey {
613    /// The provider id: `fireworks` or `anthropic`.
614    pub provider: String,
615    /// The API key (e.g. `fw_...` or `sk-ant-...`).
616    pub key: String,
617}
618
619/// The JSON wire format for an index.html payload that carries an embedded
620/// key (§3.2 — the Rust↔JS contract). Serialized as
621/// `{"html":"<page>","embeddedKey":{"provider":"...","key":"..."}}` so the
622/// decrypt shell can extract the key and the page HTML from a single
623/// decrypted blob. When no key is embedded, the payload is plain HTML (no
624/// JSON wrapping) — the decrypt shell detects this by trying `JSON.parse`
625/// and falling back to treating the plaintext as HTML.
626#[derive(Serialize)]
627struct EmbeddedPayload<'a> {
628    /// The original page HTML (the content that would be encrypted on its own
629    /// when no key is embedded).
630    html: &'a str,
631    /// The embedded API key, carried alongside the HTML so the decrypt shell
632    /// can set `window.__btEmbeddedKey` before rendering.
633    #[serde(rename = "embeddedKey")]
634    embedded_key: &'a EmbeddedKey,
635}
636
637/// Encrypt all content files in a planned site, replacing them with decrypt
638/// shells (for HTML pages) or base64-encoded encrypted payloads (for JSON
639/// files).
640///
641/// Pure (§5.1): transforms a [`SiteFiles`] into an encrypted [`SiteFiles`].
642/// Infrastructure files (lesson-runner.js, feedback.js, styles.css, etc.) are
643/// passed through unchanged. Content files (index.html, lessons/*.json,
644/// lessons.json, eval-results.html) are encrypted with AES-256-GCM + PBKDF2.
645///
646/// When `embed_key` is `Some`, the `index.html` payload is a JSON object
647/// `{"html":"...","embeddedKey":{...}}` (§3.2) so the decrypt shell can
648/// extract the key and pre-load it into the learner's sessionStorage. The key
649/// is inside the ciphertext — never plaintext in any shipped file.
650///
651/// `plan_site` is unchanged — this is a separate post-processing step (§2.1),
652/// so the 55+ existing `plan_site` tests are unaffected.
653pub fn encrypt_site_files(
654    site: &SiteFiles,
655    password: &str,
656    embed_key: Option<&EmbeddedKey>,
657    rng: &mut (impl RngCore + CryptoRng),
658) -> SiteFiles {
659    let mut encrypted_files = Vec::with_capacity(site.files().len());
660    for file in site.files() {
661        let path_str = file.path.to_string_lossy();
662        if path_str == "index.html" || path_str == "eval-results.html" {
663            // When an embed key is present, the index.html payload is a JSON
664            // object {"html":"...","embeddedKey":{...}} so the decrypt shell
665            // can extract the key and set window.__btEmbeddedKey before
666            // rendering. eval-results.html does not load feedback.js, so it
667            // does not need the embedded key — it stays as plain encrypted
668            // HTML (the decrypt shell detects this via JSON.parse fallback).
669            let plaintext = if path_str == "index.html" {
670                if let Some(ek) = embed_key {
671                    serde_json::to_string(&EmbeddedPayload {
672                        html: &file.contents,
673                        embedded_key: ek,
674                    })
675                    .expect("the embedded payload serializes infallibly")
676                } else {
677                    file.contents.clone()
678                }
679            } else {
680                file.contents.clone()
681            };
682            let payload = crypto::encrypt(&plaintext, password, rng);
683            encrypted_files.push(SiteFile {
684                path: file.path.clone(),
685                contents: render_decrypt_shell(&payload),
686            });
687        } else if is_content_file(&path_str) {
688            let payload = crypto::encrypt(&file.contents, password, rng);
689            encrypted_files.push(SiteFile {
690                path: file.path.clone(),
691                contents: payload.to_base64(),
692            });
693        } else {
694            encrypted_files.push(file.clone());
695        }
696    }
697    SiteFiles {
698        files: encrypted_files,
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use crate::course::Course;
706    use serde_json::Value;
707
708    /// Load the all-R fixture course's lessons in full (slug + parsed lesson).
709    fn r_course() -> Vec<(LessonSlug, Lesson)> {
710        Course::open(Path::new(concat!(
711            env!("CARGO_MANIFEST_DIR"),
712            "/tests/fixtures/r-course"
713        )))
714        .expect("r-course opens")
715        .load_lessons()
716        .expect("r-course lessons load")
717    }
718
719    /// The all-Python `python-course` fixture's lessons in full (slug + parsed
720    /// lesson) — a matching course for the Pyodide target, mirroring `r_course`.
721    fn python_course() -> Vec<(LessonSlug, Lesson)> {
722        Course::open(Path::new(concat!(
723            env!("CARGO_MANIFEST_DIR"),
724            "/tests/fixtures/python-course"
725        )))
726        .expect("python-course opens")
727        .load_lessons()
728        .expect("python-course lessons load")
729    }
730
731    /// Find a planned file by its relative path, or panic listing what is present.
732    fn file<'a>(site: &'a SiteFiles, path: &str) -> &'a SiteFile {
733        site.files()
734            .iter()
735            .find(|f| f.path == Path::new(path))
736            .unwrap_or_else(|| {
737                let present: Vec<_> = site.files().iter().map(|f| f.path.clone()).collect();
738                panic!("planned site is missing {path}; present: {present:?}")
739            })
740    }
741
742    /// Plan a site with no eval validation — the default for the tests about
743    /// lesson assembly, not the eval-results page. Eval folding is exercised by the
744    /// dedicated tests below with an explicit [`EvalSummary`]. Uses the default
745    /// [`SiteConfig`] (max_feedback_per_session = 20) — site-config behavior is
746    /// exercised by the dedicated `feedback_rate_limit_*` tests below.
747    fn plan(lessons: &[(LessonSlug, Lesson)], target: BuildTarget) -> Result<SiteFiles, PlanError> {
748        plan_site(
749            lessons,
750            target,
751            &EvalSummary::NotValidated,
752            &SiteConfig::default(),
753        )
754    }
755
756    #[test]
757    fn build_target_language_maps_each_runtime_to_its_language() {
758        assert_eq!(BuildTarget::Webr.language(), Language::R);
759        assert_eq!(BuildTarget::Pyodide.language(), Language::Python);
760    }
761
762    #[test]
763    fn build_target_display_names_each_runtime() {
764        assert_eq!(BuildTarget::Webr.to_string(), "webr");
765        assert_eq!(BuildTarget::Pyodide.to_string(), "pyodide");
766    }
767
768    #[test]
769    fn site_lesson_from_lesson_projects_each_contract_field() {
770        let lessons = r_course();
771        let (slug, lesson) = &lessons[0];
772        let site_lesson = SiteLesson::from_lesson(slug, lesson);
773
774        // Every field is pinned so a producer mutation that swaps two sources is
775        // caught — and so the dropped `llm_evaluation_prompt` stays dropped.
776        assert_eq!(site_lesson.id, "add-two");
777        assert_eq!(site_lesson.title, "Add Two Numbers");
778        assert_eq!(site_lesson.prompt, lesson.exercise.prompt);
779        assert_eq!(site_lesson.code_template, lesson.exercise.code_template);
780        assert_eq!(site_lesson.checks, lesson.checks);
781        assert_eq!(site_lesson.packages, lesson.packages);
782        assert_eq!(site_lesson.solution, lesson.exercise.solution);
783        assert!(
784            site_lesson.solution.as_deref().unwrap().contains("x + y"),
785            "the add-two solution rides the contract verbatim"
786        );
787        assert_eq!(
788            site_lesson.hints, lesson.exercise.hints,
789            "hints ride the contract verbatim from exercise.hints"
790        );
791        assert_eq!(
792            site_lesson.gotchas, lesson.exercise.gotchas,
793            "gotchas ride the contract verbatim from exercise.gotchas"
794        );
795        assert_eq!(
796            site_lesson.success_criteria, lesson.exercise.success_criteria,
797            "success_criteria ride the contract verbatim (ADR-0020)"
798        );
799    }
800
801    #[test]
802    fn plan_site_webr_assembles_the_shell_runner_shim_and_one_json_per_lesson() {
803        let site = plan(&r_course(), BuildTarget::Webr).expect("an all-R course plans for webr");
804
805        // The page shell, the runner, and the COOP/COEP shim all land.
806        assert!(
807            file(&site, "index.html")
808                .contents
809                .contains("coi-serviceworker.js")
810        );
811        assert!(
812            file(&site, "lesson-runner.js")
813                .contents
814                .to_lowercase()
815                .contains("webr"),
816            "the runner boots webR"
817        );
818        // The shared runner core and the shim file both land (the shim's
819        // referencing from index.html is asserted above).
820        let _ = file(&site, "lesson-runner-core.js");
821        let _ = file(&site, "coi-serviceworker.js");
822
823        // One JSON per manifest entry — count matches the course, neither dropped
824        // nor duplicated.
825        let per_lesson = site
826            .files()
827            .iter()
828            .filter(|f| f.path.starts_with("lessons/"))
829            .count();
830        assert_eq!(per_lesson, 2, "two lessons -> two per-lesson JSON files");
831        let _ = file(&site, "lessons/0.json");
832        let _ = file(&site, "lessons/1.json");
833    }
834
835    #[test]
836    fn plan_site_serializes_each_lesson_to_the_browser_contract() {
837        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
838
839        // The per-lesson JSON carries exactly the contract fields the runner reads.
840        let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
841            .expect("the per-lesson JSON parses");
842        assert_eq!(first["id"], "add-two");
843        assert_eq!(first["title"], "Add Two Numbers");
844        assert!(first["checks"].as_array().unwrap().len() == 2);
845        assert!(first["solution"].as_str().unwrap().contains("x + y"));
846        assert!(
847            first["hints"].as_str().unwrap().contains("assignment"),
848            "the add-two hints ride the contract JSON verbatim"
849        );
850
851        // The lessons index is the ordered slug list the runner enumerates — by
852        // index, so a lesson slug never becomes a filesystem path.
853        let index: Value =
854            serde_json::from_str(&file(&site, "lessons.json").contents).expect("index parses");
855        assert_eq!(index, serde_json::json!(["add-two", "square"]));
856    }
857
858    #[test]
859    fn plan_site_serializes_a_missing_solution_as_json_null_not_dropped() {
860        // solution is optional (ADR-0008): a lesson without one must still build,
861        // with the field present-but-null so the contract shape stays stable for the
862        // runner — never silently dropped (which a later `skip_serializing_if` would
863        // do, breaking `lessons[i].solution`). course_basic's R lesson carries none.
864        let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
865            env!("CARGO_MANIFEST_DIR"),
866            "/tests/fixtures/course_basic"
867        )))
868        .expect("course_basic opens")
869        .load_lessons()
870        .expect("course_basic lessons load")
871        .into_iter()
872        .filter(|(_, lesson)| lesson.language == Language::R)
873        .collect();
874        assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
875
876        let site =
877            plan(&r_lessons, BuildTarget::Webr).expect("a solution-less R course still plans");
878        let lesson: Value =
879            serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
880        assert!(
881            lesson.get("solution").is_some() && lesson["solution"].is_null(),
882            "a missing solution serializes as null, never dropped: {lesson}"
883        );
884    }
885
886    #[test]
887    fn plan_site_serializes_hints_when_present() {
888        // hints is optional: a lesson that carries one must serialize the text
889        // verbatim into the per-lesson JSON so the browser runner can render it
890        // in an expandable <details>. r-course's add-two lesson carries hints.
891        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
892        let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
893            .expect("the per-lesson JSON parses");
894        assert!(
895            first["hints"].is_string(),
896            "a lesson with hints serializes them as a JSON string, got: {first}"
897        );
898        assert!(
899            first["hints"].as_str().unwrap().contains("assignment"),
900            "the hints text rides the contract verbatim: {first}"
901        );
902    }
903
904    #[test]
905    fn plan_site_serializes_a_missing_hints_as_json_null_not_dropped() {
906        // hints is optional: a lesson without one must still build, with the
907        // field present-but-null so the contract shape stays stable for the
908        // runner — never silently dropped (which a `skip_serializing_if` would
909        // do, breaking `lessons[i].hints`). course_basic's R lesson carries none.
910        let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
911            env!("CARGO_MANIFEST_DIR"),
912            "/tests/fixtures/course_basic"
913        )))
914        .expect("course_basic opens")
915        .load_lessons()
916        .expect("course_basic lessons load")
917        .into_iter()
918        .filter(|(_, lesson)| lesson.language == Language::R)
919        .collect();
920        assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
921
922        let site = plan(&r_lessons, BuildTarget::Webr).expect("a hints-less R course still plans");
923        let lesson: Value =
924            serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
925        assert!(
926            lesson.get("hints").is_some() && lesson["hints"].is_null(),
927            "a missing hints serializes as null, never dropped: {lesson}"
928        );
929    }
930
931    #[test]
932    fn plan_site_serializes_gotchas_when_present() {
933        // gotchas is optional: a lesson that carries one must serialize the text
934        // verbatim into the per-lesson JSON so the browser runner can render it.
935        // We parse a lesson with bullet-formatted gotchas and pair it with a slug
936        // from the r-course fixture (the slug is just an identifier).
937        use crate::lesson::Lesson;
938        let yaml = r#"
939lesson_name: "Gotcha Lesson"
940language: R
941exercise:
942  prompt: "Write add_two(x, y)."
943  gotchas: |
944    - R uses '<-' for assignment, not '='.
945    - Functions return their last expression automatically.
946  llm_evaluation_prompt: "Grade this: {student_code}"
947"#;
948        let lesson = Lesson::parse(yaml).expect("a lesson with gotchas should parse");
949        let slug = r_course()[0].0.clone();
950        let lessons = [(slug, lesson)];
951        let site = plan(&lessons, BuildTarget::Webr).expect("plans");
952        let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
953            .expect("the per-lesson JSON parses");
954        assert!(
955            first["gotchas"].is_string(),
956            "a lesson with gotchas serializes them as a JSON string, got: {first}"
957        );
958        assert!(
959            first["gotchas"].as_str().unwrap().contains("'<-'"),
960            "the gotchas text rides the contract verbatim: {first}"
961        );
962    }
963
964    #[test]
965    fn plan_site_serializes_a_missing_gotchas_as_json_null_not_dropped() {
966        // gotchas is optional: a lesson without one must still build, with the
967        // field present-but-null so the contract shape stays stable for the
968        // runner — never silently dropped (which a `skip_serializing_if` would
969        // do, breaking `lessons[i].gotchas`). course_basic's R lesson carries
970        // none. Mirrors the solution: null and hints: null precedents.
971        let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
972            env!("CARGO_MANIFEST_DIR"),
973            "/tests/fixtures/course_basic"
974        )))
975        .expect("course_basic opens")
976        .load_lessons()
977        .expect("course_basic lessons load")
978        .into_iter()
979        .filter(|(_, lesson)| lesson.language == Language::R)
980        .collect();
981        assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
982
983        let site =
984            plan(&r_lessons, BuildTarget::Webr).expect("a gotchas-less R course still plans");
985        let lesson: Value =
986            serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
987        assert!(
988            lesson.get("gotchas").is_some() && lesson["gotchas"].is_null(),
989            "a missing gotchas serializes as null, never dropped: {lesson}"
990        );
991        assert!(
992            lesson.get("success_criteria").is_some(),
993            "success_criteria is always serialized, never dropped: {lesson}"
994        );
995    }
996
997    #[test]
998    fn plan_site_serializes_packages_as_array_even_when_empty() {
999        // ADR-0011: packages must always serialize as an array — empty when the
1000        // lesson declares none, never null or absent — so the JS runtime
1001        // contract shape stays stable (mirrors the solution: null precedent).
1002        // The r-course fixture lessons have no `packages` key.
1003        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1004        let lesson: Value =
1005            serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
1006        assert!(
1007            lesson["packages"].is_array(),
1008            "packages must be an array, not null/absent: {lesson}"
1009        );
1010        assert!(
1011            lesson["packages"].as_array().unwrap().is_empty(),
1012            "a lesson without packages must emit an empty array: {lesson}"
1013        );
1014    }
1015
1016    #[test]
1017    fn plan_site_refuses_an_r_course_built_for_the_pyodide_target() {
1018        // Symmetric twin of the happy path: a language/target mismatch is refused
1019        // (§1.3.1) and returns no SiteFiles, so write_site is never reached.
1020        let err = plan(&r_course(), BuildTarget::Pyodide)
1021            .expect_err("an R course cannot be built for the Python target");
1022        // PlanError has a single variant today, so a catch-all arm would be
1023        // unreachable; assert the shape with `matches!`. A future variant keeps
1024        // this honest — the R/Pyodide fields are pinned, not wildcarded.
1025        assert!(
1026            matches!(
1027                &err,
1028                PlanError::LanguageMismatch {
1029                    lesson_language: Language::R,
1030                    target: BuildTarget::Pyodide,
1031                    ..
1032                }
1033            ),
1034            "expected an R-vs-Pyodide language mismatch, got {err:?}"
1035        );
1036    }
1037
1038    #[test]
1039    fn plan_site_pyodide_assembles_the_shell_runner_core_shim_and_one_json_per_lesson() {
1040        let site = plan(&python_course(), BuildTarget::Pyodide)
1041            .expect("an all-Python course plans for pyodide");
1042
1043        // The shell boots the Pyodide runtime and references the COOP/COEP shim;
1044        // the runner boots Pyodide — not webR — so this is genuinely the Python
1045        // target and not a verbatim copy of the webR assets.
1046        let index = &file(&site, "index.html").contents;
1047        assert!(index.contains("coi-serviceworker.js"));
1048        assert!(
1049            index.to_lowercase().contains("pyodide"),
1050            "index.html boots the Pyodide runtime"
1051        );
1052        assert!(
1053            file(&site, "lesson-runner.js")
1054                .contents
1055                .to_lowercase()
1056                .contains("pyodide"),
1057            "the runner boots Pyodide"
1058        );
1059        // The shared runner core and the shim both land (referencing asserted above).
1060        let _ = file(&site, "lesson-runner-core.js");
1061        let _ = file(&site, "coi-serviceworker.js");
1062
1063        // One JSON per manifest entry — the same per-lesson contract the webR
1064        // target produces, carried across the seam unchanged (§3.2).
1065        let per_lesson = site
1066            .files()
1067            .iter()
1068            .filter(|f| f.path.starts_with("lessons/"))
1069            .count();
1070        assert_eq!(per_lesson, 1, "one lesson -> one per-lesson JSON file");
1071        let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
1072            .expect("the per-lesson JSON parses");
1073        assert_eq!(first["id"], "add-two");
1074        assert_eq!(first["title"], "Add Two Numbers");
1075        let index_json: Value =
1076            serde_json::from_str(&file(&site, "lessons.json").contents).expect("index parses");
1077        assert_eq!(index_json, serde_json::json!(["add-two"]));
1078    }
1079
1080    #[test]
1081    fn plan_site_carries_the_same_shared_core_and_shim_across_both_targets() {
1082        // The seam's payoff (§3.2, §4.2): the shared runner core and the COOP/COEP
1083        // shim are byte-identical whichever target produced them — only the shell
1084        // and runner glue differ. A divergence here means a target forked the
1085        // shared scaffolding instead of reusing it.
1086        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1087        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1088        for shared in [
1089            "lesson-runner-core.js",
1090            "coi-serviceworker.js",
1091            "styles.css",
1092            "codemirror.js",
1093        ] {
1094            assert_eq!(
1095                file(&webr, shared).contents,
1096                file(&pyodide, shared).contents,
1097                "{shared} must be identical across targets"
1098            );
1099        }
1100        // ...while the per-target shell genuinely differs (R vs Python boot) —
1101        // but only by the 3 known diffs (title, boot text, CDN script).
1102        assert_ne!(
1103            file(&webr, "index.html").contents,
1104            file(&pyodide, "index.html").contents,
1105            "each target carries its own shell"
1106        );
1107    }
1108
1109    #[test]
1110    fn plan_site_styles_css_is_present_for_both_targets() {
1111        // AC-1 predicate 1: styles.css lands as a planned file for both targets,
1112        // not just one — a target that forgot to include it would miss here.
1113        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1114        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1115        let _ = file(&webr, "styles.css");
1116        let _ = file(&pyodide, "styles.css");
1117    }
1118
1119    #[test]
1120    fn plan_site_shells_have_link_and_no_inline_style() {
1121        // AC-1 predicate 2: both shells reference the external stylesheet and
1122        // have zero inline <style> blocks. A shell that keeps the old inline
1123        // <style> AND adds the <link> would be caught here.
1124        for (target, course) in [
1125            (BuildTarget::Webr, r_course()),
1126            (BuildTarget::Pyodide, python_course()),
1127        ] {
1128            let site = plan(&course, target).expect("plans");
1129            let html = &file(&site, "index.html").contents;
1130            // Accept both self-closing <link ... /> and HTML5 <link ... >
1131            let link_self_closing = html.contains(r#"<link rel="stylesheet" href="styles.css" />"#);
1132            let link_html5 = html.contains(r#"<link rel="stylesheet" href="styles.css">"#);
1133            assert!(
1134                link_self_closing || link_html5,
1135                "{target} index.html must link styles.css"
1136            );
1137            assert!(
1138                !html.contains("<style>"),
1139                "{target} index.html must not contain inline <style>"
1140            );
1141        }
1142    }
1143
1144    #[test]
1145    fn plan_site_styles_css_declares_tokens_and_uses_them() {
1146        // AC-1 predicate 3: styles.css declares --bt- custom properties in :root
1147        // and has >=4 rules referencing var(--bt-). This proves tokens are used,
1148        // not declared-dead.
1149        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1150        let css = &file(&site, "styles.css").contents;
1151        assert!(css.contains(":root"), "styles.css must have a :root block");
1152        assert!(
1153            css.contains("--bt-"),
1154            "styles.css must declare --bt- custom properties"
1155        );
1156        let var_refs: Vec<&str> = css
1157            .lines()
1158            .filter(|line| line.contains("var(--bt-"))
1159            .collect();
1160        assert!(
1161            var_refs.len() >= 4,
1162            "styles.css must have >= 4 rules referencing var(--bt-), got {}: {:?}",
1163            var_refs.len(),
1164            var_refs
1165        );
1166    }
1167
1168    #[test]
1169    fn plan_site_workspace_styles_use_tokens_and_status_renders_as_pill() {
1170        // AC-2: workspace CSS contract — selectors present, token usage floor,
1171        // no hardcoded hex, badge/pill via data-status attribute selectors (not
1172        // class selectors), every AC-2-added token consumed outside :root.
1173        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1174        let css = &file(&site, "styles.css").contents;
1175
1176        // 1. Workspace selectors present
1177        for selector in [
1178            ".lesson-picker",
1179            "#lesson-title",
1180            "#lesson-prompt",
1181            "#submission",
1182            ".controls",
1183            "#run",
1184            "#submit",
1185            "#lesson-status",
1186            "#output",
1187        ] {
1188            assert!(
1189                css.contains(selector),
1190                "styles.css must contain selector `{selector}`"
1191            );
1192        }
1193
1194        // 2. ≥6 var(--bt-) references within workspace rules (exclude :root).
1195        // Split on the workspace section marker to isolate workspace rules.
1196        let workspace_marker = "/* === workspace === */";
1197        assert!(
1198            css.contains(workspace_marker),
1199            "styles.css must have a workspace section marker"
1200        );
1201        let after_marker = css
1202            .split(workspace_marker)
1203            .nth(1)
1204            .expect("workspace section exists");
1205        // Only count lines in the workspace section that contain var(--bt-)
1206        let workspace_var_refs: Vec<&str> = after_marker
1207            .lines()
1208            .filter(|line| line.contains("var(--bt-"))
1209            .collect();
1210        assert!(
1211            workspace_var_refs.len() >= 6,
1212            "styles.css workspace section must have >= 6 var(--bt-) references, got {}: {:?}",
1213            workspace_var_refs.len(),
1214            workspace_var_refs
1215        );
1216
1217        // 3. No hardcoded hex in workspace rules — exclude the dark-mode @media block
1218        //    (which carries hex token overrides by design).
1219        // Match exactly 3, 6, or 8 hex digits after # (full color or shorthand,
1220        // with optional alpha). Avoids matching non-color hex patterns like
1221        // "#feedback" in comments (7 chars, but {6} and {3} won't match 7).
1222        let before_dark_mode = after_marker
1223            .split("/* ── Dark mode ")
1224            .next()
1225            .unwrap_or(after_marker);
1226        let hex_re =
1227            regex_lite::Regex::new(r"#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\b|#[0-9a-fA-F]{3}\b")
1228                .unwrap();
1229        if let Some(hit) = hex_re.find(before_dark_mode) {
1230            panic!(
1231                "workspace rules must not contain hardcoded hex literals, found `{}`",
1232                hit.as_str()
1233            );
1234        }
1235
1236        // 4. Exactly 5 #lesson-status[data-status="..."] rules
1237        //    (4 status values + 1 dark-mode idle override in @media block)
1238        let status_selector_re =
1239            regex_lite::Regex::new(r#"#lesson-status\[data-status="[^"]+"\]"#).unwrap();
1240        let status_matches: Vec<_> = status_selector_re.find_iter(css).collect();
1241        assert_eq!(
1242            status_matches.len(),
1243            5,
1244            "expected exactly 5 `#lesson-status[data-status=\"...\"]` rules \
1245             (4 status values + 1 dark-mode idle override), got {}: {:?}",
1246            status_matches.len(),
1247            status_matches
1248        );
1249
1250        // 5. No .status-* class selectors
1251        let status_class_re = regex_lite::Regex::new(r"\.status-(idle|running|pass|fail)").unwrap();
1252        assert!(
1253            !status_class_re.is_match(css),
1254            "styles.css must not contain .status-* class selectors"
1255        );
1256
1257        // 6. Every AC-2-added token used >= 1 outside :root.
1258        // The 9 tokens from the AC-2 token table — some may already be declared
1259        // by AC-1 as forward-declarations. We check each declared in :root has
1260        // a var(--bt-...) consumer outside :root.
1261        let ac2_tokens = [
1262            "--bt-color-status-idle",
1263            "--bt-color-border",
1264            "--bt-color-brand-hover",
1265            "--bt-space-xs",
1266            "--bt-space-sm",
1267            "--bt-space-md",
1268            "--bt-space-lg",
1269            "--bt-shadow-sm",
1270            "--bt-radius-pill",
1271        ];
1272        // Parse the :root block to find which tokens AC-2's section adds.
1273        // We use the workspace marker to split: tokens in :root but consumed
1274        // by workspace rules are AC-2's responsibility.
1275        let root_block: &str = &css[..css.find(":root").unwrap_or(0)];
1276        let _root_block = root_block; // suppress unused warning in red phase
1277        let outside_root = &css[css.find("}").map(|i| i + 1).unwrap_or(0)..];
1278
1279        for token in &ac2_tokens {
1280            let token_declared = css.contains(&format!("{token}:"));
1281            let token_consumed = outside_root.contains(&format!("var({token}"));
1282            if token_declared {
1283                assert!(
1284                    token_consumed,
1285                    "AC-2-added token {token} is declared in :root but never used via var() \
1286                     outside :root (dead token)"
1287                );
1288            }
1289        }
1290    }
1291
1292    #[test]
1293    fn plan_site_shells_contain_semantic_regions() {
1294        // AC-1 predicate 4: both shells have the required semantic layout regions
1295        // with the expected structure.
1296        for (target, course) in [
1297            (BuildTarget::Webr, r_course()),
1298            (BuildTarget::Pyodide, python_course()),
1299        ] {
1300            let site = plan(&course, target).expect("plans");
1301            let html = &file(&site, "index.html").contents;
1302
1303            // Exactly one of each semantic region
1304            let header_count = html.matches(r#"<header class="site-header">"#).count();
1305            let main_count = html.matches(r#"<main class="workspace">"#).count();
1306            let footer_count = html.matches(r#"<footer class="site-footer">"#).count();
1307            assert_eq!(
1308                header_count, 1,
1309                "{target}: expected 1 <header class=\"site-header\">"
1310            );
1311            assert_eq!(
1312                main_count, 1,
1313                "{target}: expected 1 <main class=\"workspace\">"
1314            );
1315            assert_eq!(
1316                footer_count, 1,
1317                "{target}: expected 1 <footer class=\"site-footer\">"
1318            );
1319
1320            // Header contains <h1>blendtutor</h1>
1321            assert!(
1322                html.contains("<h1>blendtutor</h1>"),
1323                "{target}: header must contain <h1>blendtutor</h1>"
1324            );
1325
1326            // All 6 data-test hooks present
1327            for hook in [
1328                "lesson-select",
1329                "submission",
1330                "run",
1331                "lesson-status",
1332                "output",
1333                "feedback",
1334            ] {
1335                let attr = format!(r#"data-test="{}""#, hook);
1336                assert!(
1337                    html.contains(&attr),
1338                    "{target}: missing data-test=\"{hook}\""
1339                );
1340            }
1341
1342            // submit carries data-action="submit"
1343            assert!(
1344                html.contains(r#"data-action="submit""#),
1345                "{target}: submit must carry data-action=\"submit\""
1346            );
1347
1348            // lesson-status has data-status="idle"
1349            assert!(
1350                html.contains(r#"data-status="idle""#),
1351                "{target}: lesson-status must have data-status=\"idle\""
1352            );
1353
1354            // All JS-required IDs present
1355            for id in [
1356                "boot-status",
1357                "lesson-title",
1358                "lesson-prompt",
1359                "submission",
1360                "lesson-select",
1361                "output",
1362                "run",
1363                "feedback",
1364                "submit",
1365                "lesson-status",
1366            ] {
1367                let attr = format!(r#"id="{}""#, id);
1368                assert!(html.contains(&attr), "{target}: missing id=\"{id}\"");
1369            }
1370
1371            // AC-2 clause 2: the submission mount is a <div> (CM6 parent), NOT a
1372            // <textarea>. A shell that kept the old textarea fails here; a shell
1373            // with both fails here too (exactly one div, zero textarea).
1374            assert!(
1375                html.contains(r#"<div id="submission""#),
1376                "{target}: submission mount must be <div id=\"submission\"> (CM6 parent)"
1377            );
1378            assert!(
1379                !html.contains(r#"<textarea id="submission""#),
1380                "{target}: submission mount must NOT be a <textarea> (replaced by CM6 editor)"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn plan_site_shells_have_correct_head_load_order() {
1387        // AC-1 predicate 5: head load order preserved — coi-serviceworker.js
1388        // before styles.css link, and the link is before the body-end module scripts.
1389        for (target, course) in [
1390            (BuildTarget::Webr, r_course()),
1391            (BuildTarget::Pyodide, python_course()),
1392        ] {
1393            let site = plan(&course, target).expect("plans");
1394            let html = &file(&site, "index.html").contents;
1395
1396            let coi_pos = html
1397                .find(r#"src="coi-serviceworker.js""#)
1398                .expect("coi-serviceworker.js must be referenced");
1399            let link_pos = html
1400                .find(r#"href="styles.css""#)
1401                .expect("styles.css link must be present");
1402            assert!(
1403                coi_pos < link_pos,
1404                "{target}: coi-serviceworker.js must appear before styles.css link"
1405            );
1406
1407            // Module scripts come after the stylesheet link (in body-end)
1408            let link_close_pos = html[link_pos..]
1409                .find('>')
1410                .map(|p| link_pos + p)
1411                .expect("link tag closes");
1412            let runner_pos = html
1413                .find(r#"src="lesson-runner.js""#)
1414                .expect("lesson-runner.js must be referenced");
1415            assert!(
1416                link_close_pos < runner_pos,
1417                "{target}: styles.css link must appear before lesson-runner.js script"
1418            );
1419        }
1420    }
1421
1422    #[test]
1423    fn plan_site_shells_differ_only_by_three_known_diffs() {
1424        // AC-1 predicate 6: after normalizing (stripping) the 3 known diffs,
1425        // the two shells are byte-identical. This catches a 4th unintended
1426        // divergence between targets.
1427        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1428        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1429
1430        let webr_html = &file(&webr, "index.html").contents;
1431        let pyodide_html = &file(&pyodide, "index.html").contents;
1432
1433        // Normalize: strip <title> contents, #boot-status text, and pyodide CDN
1434        // script block (comment + script tag, only present in pyodide shell).
1435        let normalize = |html: &str| -> String {
1436            let mut s = html.to_string();
1437            // Strip <title> content (keep the tags)
1438            s = s.replace(
1439                "<title>blendtutor — interactive R lessons</title>",
1440                "<title></title>",
1441            );
1442            s = s.replace(
1443                "<title>blendtutor — interactive Python lessons</title>",
1444                "<title></title>",
1445            );
1446            // Strip boot-status text
1447            s = s.replace("Booting webR…", "");
1448            s = s.replace("Booting Pyodide…", "");
1449            // Strip the pyodide CDN comment + script block (only present in pyodide),
1450            // including the indentation whitespace and newline before the comment.
1451            let pyodide_block_start = "<!--\n      The Pyodide runtime";
1452            if let Some(start) = s.find(pyodide_block_start) {
1453                // Back up to the preceding newline to drop the indentation too
1454                let preceding = s[..start].rfind('\n').map(|i| i + 1).unwrap_or(start);
1455                // Find the closing </script> after the block start
1456                if let Some(end) = s[start..].find("</script>") {
1457                    let block_end = start + end + "</script>".len();
1458                    // Strip the trailing newline after the script tag
1459                    let after = &s[block_end..];
1460                    let strip_newline = after.strip_prefix('\n').unwrap_or(after);
1461                    s = format!("{}{}", &s[..preceding], strip_newline);
1462                }
1463            }
1464            s
1465        };
1466
1467        assert_eq!(
1468            normalize(webr_html),
1469            normalize(pyodide_html),
1470            "after normalizing 3 known diffs (title, boot text, CDN script), \
1471             shells must be byte-identical"
1472        );
1473    }
1474
1475    #[test]
1476    fn plan_site_assembles_the_shared_byok_feedback_backend() {
1477        // Slice 18: the FeedbackBackend contract + its byok-anthropic impl ship as a
1478        // single shared feedback.js (§4.2) — the Anthropic BYOK call is identical for
1479        // R and Python lessons — referenced by each target's shell. A per-target fork
1480        // or a shell that never loads it both fail here.
1481        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1482        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1483
1484        for site in [&webr, &pyodide] {
1485            let _ = file(site, "feedback.js");
1486            assert!(
1487                file(site, "index.html").contents.contains("feedback.js"),
1488                "each shell must load feedback.js (the seam is dead if unreferenced)"
1489            );
1490        }
1491        assert_eq!(
1492            file(&webr, "feedback.js").contents,
1493            file(&pyodide, "feedback.js").contents,
1494            "feedback.js is shared — byte-identical across targets, not forked"
1495        );
1496    }
1497
1498    #[test]
1499    fn write_site_writes_every_planned_file_to_disk() {
1500        let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1501        let tmp = tempfile::tempdir().unwrap();
1502        write_site(tmp.path(), &site).expect("the site writes");
1503
1504        // The top-level shell and a nested per-lesson file both land — the latter
1505        // proves the parent directory is created.
1506        assert!(tmp.path().join("index.html").is_file());
1507        let lesson_json = std::fs::read_to_string(tmp.path().join("lessons/0.json"))
1508            .expect("the nested lesson JSON is written");
1509        let parsed: Value = serde_json::from_str(&lesson_json).expect("it parses");
1510        assert_eq!(parsed["id"], "add-two");
1511    }
1512
1513    #[test]
1514    fn plan_error_language_mismatch_displays_the_clash_with_no_nested_source() {
1515        let mismatch = plan(&r_course(), BuildTarget::Pyodide).unwrap_err();
1516        let text = mismatch.to_string().to_lowercase();
1517        assert!(
1518            text.contains("does not match") && text.contains("language"),
1519            "a mismatch names the language/target clash, got: {text}"
1520        );
1521
1522        // A std::error::Error with no nested source.
1523        let as_error: &dyn Error = &mismatch;
1524        assert!(as_error.source().is_none());
1525    }
1526
1527    #[test]
1528    fn eval_results_html_validated_renders_the_accuracy_and_validated_marker() {
1529        // The validated page shows the report's accuracy as a percentage and
1530        // carries the validated marker, never the not-validated one (§1.2). Two
1531        // different accuracies render two different figures, so the percentage is
1532        // derived from the summary, not a constant.
1533        let two_thirds = eval_results_html(&EvalSummary::Validated {
1534            accuracy: 0.6666666666666666,
1535        });
1536        assert!(
1537            two_thirds.contains("67%"),
1538            "2/3 should render 67%: {two_thirds}"
1539        );
1540        assert!(two_thirds.contains(EvalSummary::VALIDATED_MARKER));
1541        assert!(!two_thirds.contains(EvalSummary::NOT_VALIDATED_MARKER));
1542
1543        let half = eval_results_html(&EvalSummary::Validated { accuracy: 0.5 });
1544        assert!(
1545            half.contains("50%"),
1546            "the figure tracks the summary, not a hardcoded constant: {half}"
1547        );
1548
1549        // The inclusive bounds render their whole-number percent — 0% is a real
1550        // (validated, all-wrong) result, distinct from the not-validated state.
1551        assert!(eval_results_html(&EvalSummary::Validated { accuracy: 0.0 }).contains("0%"));
1552        let perfect = eval_results_html(&EvalSummary::Validated { accuracy: 1.0 });
1553        assert!(
1554            perfect.contains("100%"),
1555            "1.0 should render 100%: {perfect}"
1556        );
1557    }
1558
1559    #[test]
1560    fn eval_results_html_not_validated_renders_an_explicit_marker() {
1561        // The not-validated page is explicit (§1.2): it carries the not-validated
1562        // marker and plain human copy, and is *not* the validated state — so a
1563        // missing report is never mistaken for a real result.
1564        let page = eval_results_html(&EvalSummary::NotValidated);
1565        assert!(page.contains(EvalSummary::NOT_VALIDATED_MARKER));
1566        assert!(
1567            page.to_lowercase().contains("not been validated"),
1568            "the page states plainly the lessons were not validated: {page}"
1569        );
1570        assert!(!page.contains(EvalSummary::VALIDATED_MARKER));
1571    }
1572
1573    #[test]
1574    fn eval_summary_from_report_json_takes_the_accuracy_as_is() {
1575        // §3.2: the build consumes the Slice-13 artifact as-is — the accuracy is
1576        // read straight from the JSON, never recomputed from the cases. A report
1577        // whose recorded accuracy (0.5) contradicts its single all-matched case
1578        // (which would re-derive 1.0) proves it: the summary reflects the recorded
1579        // figure, not a re-scored one.
1580        let json = r#"{"cases":[{"expected":"correct","actual":"correct","matched":true}],"accuracy":0.5}"#;
1581        let summary = eval_summary_from_report_json(json).expect("a well-formed report parses");
1582        assert_eq!(summary, EvalSummary::Validated { accuracy: 0.5 });
1583    }
1584
1585    #[test]
1586    fn eval_summary_from_report_json_rejects_a_malformed_report() {
1587        // A present-but-unreadable report is a loud error, not a silent drop to
1588        // not-validated — a corrupt artifact must never quietly unvalidate a course.
1589        assert!(matches!(
1590            eval_summary_from_report_json("{ not json"),
1591            Err(EvalReportError::Malformed(_))
1592        ));
1593        // Missing the accuracy field is equally unreadable for the page's purpose.
1594        assert!(matches!(
1595            eval_summary_from_report_json(r#"{"cases":[]}"#),
1596            Err(EvalReportError::Malformed(_))
1597        ));
1598    }
1599
1600    #[test]
1601    fn eval_summary_from_report_json_rejects_an_out_of_range_accuracy() {
1602        // §1.3.1: a real report's accuracy is matched/total, always in [0.0, 1.0].
1603        // A figure outside that is a corrupt/hand-edited artifact and is rejected at
1604        // the read boundary, so the page never renders nonsense like 200% or -50%.
1605        // (JSON has no NaN/Inf literal, so only finite out-of-range is reachable
1606        // from an artifact; the guard rejects NaN/Inf too, defensively.)
1607        for bogus in ["2.0", "-0.5", "1.0001"] {
1608            let json = format!(r#"{{"cases":[],"accuracy":{bogus}}}"#);
1609            let result = eval_summary_from_report_json(&json);
1610            assert!(
1611                matches!(result, Err(EvalReportError::AccuracyOutOfRange { .. })),
1612                "accuracy {bogus} must be rejected as out of range, got {result:?}"
1613            );
1614        }
1615        // The inclusive bounds 0.0 and 1.0 are valid — a perfect or zero score.
1616        for ok in ["0.0", "1.0"] {
1617            let json = format!(r#"{{"cases":[],"accuracy":{ok}}}"#);
1618            assert!(
1619                eval_summary_from_report_json(&json).is_ok(),
1620                "accuracy {ok} is on the inclusive boundary and must be accepted"
1621            );
1622        }
1623    }
1624
1625    #[test]
1626    fn eval_report_error_displays_each_variant_and_chains_its_source() {
1627        // The malformed variant names the JSON failure and preserves the underlying
1628        // serde error as its source, so a build failure is diagnosable.
1629        let malformed = eval_summary_from_report_json("{ not json").unwrap_err();
1630        assert!(
1631            malformed.to_string().contains("not valid Slice-13 JSON"),
1632            "malformed Display names the JSON failure, got: {malformed}"
1633        );
1634        let as_error: &dyn Error = &malformed;
1635        assert!(
1636            as_error.source().is_some(),
1637            "a malformed report chains its serde source for diagnosis"
1638        );
1639
1640        // The out-of-range variant names the offending figure and the bound, and is
1641        // self-contained — a validation failure, no nested cause.
1642        let out_of_range =
1643            eval_summary_from_report_json(r#"{"cases":[],"accuracy":2.0}"#).unwrap_err();
1644        let text = out_of_range.to_string();
1645        assert!(
1646            text.contains('2') && text.contains("range"),
1647            "out-of-range Display names the bad figure and the range, got: {text}"
1648        );
1649        let as_error: &dyn Error = &out_of_range;
1650        assert!(as_error.source().is_none());
1651    }
1652
1653    #[test]
1654    fn plan_site_folds_the_eval_results_page_for_each_state() {
1655        // The eval-results page rides every built site (§4.1), in whichever state
1656        // the EvalSummary carries — the same plan_site path handles both states.
1657        let validated = plan_site(
1658            &r_course(),
1659            BuildTarget::Webr,
1660            &EvalSummary::Validated {
1661                accuracy: 0.6666666666666666,
1662            },
1663            &SiteConfig::default(),
1664        )
1665        .expect("plans");
1666        let validated_page = file(&validated, "eval-results.html").contents.clone();
1667        assert!(validated_page.contains("67%"));
1668        assert!(validated_page.contains(EvalSummary::VALIDATED_MARKER));
1669        assert!(!validated_page.contains(EvalSummary::NOT_VALIDATED_MARKER));
1670
1671        let unvalidated = plan_site(
1672            &r_course(),
1673            BuildTarget::Webr,
1674            &EvalSummary::NotValidated,
1675            &SiteConfig::default(),
1676        )
1677        .expect("plans");
1678        assert!(
1679            file(&unvalidated, "eval-results.html")
1680                .contents
1681                .contains(EvalSummary::NOT_VALIDATED_MARKER)
1682        );
1683
1684        // Target-independent: the same summary renders the same page whichever
1685        // runtime the site serves (§4.1) — the page is not forked per target.
1686        let pyodide = plan_site(
1687            &python_course(),
1688            BuildTarget::Pyodide,
1689            &EvalSummary::Validated {
1690                accuracy: 0.6666666666666666,
1691            },
1692            &SiteConfig::default(),
1693        )
1694        .expect("plans");
1695        assert_eq!(
1696            file(&pyodide, "eval-results.html").contents,
1697            validated_page,
1698            "the eval-results page is identical across targets for the same summary"
1699        );
1700    }
1701
1702    #[test]
1703    fn plan_site_emits_vendored_codemirror_bundle() {
1704        // AC-1 (code-editor): a pre-built CodeMirror 6 ESM bundle is vendored as a
1705        // committed static asset at assets/shared/codemirror.js, embedded at compile
1706        // time via include_str! (CODEMIRROR_JS const) and emitted to built sites for
1707        // BOTH targets through the shared `assemble` step — not forked per target.
1708        //
1709        // 8 clauses pin the invariant (§1.5 — predicates, not coincident shape):
1710        //   1. codemirror.js present in SiteFiles for both targets
1711        //   2. byte-identical across targets (shared, not forked — §4.2)
1712        //   3. contents.len() > 10_000 (catches empty/comment-only stub)
1713        //   4. contains EditorView (CM6 core export — catches wrong-library/UMD)
1714        //   5. contains evidence of lang-r + lang-python + lineNumbers +
1715        //      highlightActiveLine + bracketMatching + indentWithTab
1716        //   6. no `new Worker(` (COOP/COEP isolation — no web workers)
1717        //   7. deterministic order: after styles.css AND before any lessons/ file
1718        //      (full positional invariant, not weak "after styles.css" proxy)
1719        //   8. CODEMIRROR_JS const compiles — proved by the test running
1720        //      (include_str! refuses a missing asset at compile time — §1.3.1)
1721        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1722        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1723
1724        // Clause 1: codemirror.js lands as a planned file for both targets — a
1725        // target that forgot to include it (or embedded it in webr.rs/pyodide.rs
1726        // instead of shared mod.rs) misses here.
1727        let webr_cm = file(&webr, "codemirror.js").contents.clone();
1728        let pyodide_cm = file(&pyodide, "codemirror.js").contents.clone();
1729
1730        // Clause 2: byte-identical across targets — the bundle is shared, not
1731        // forked per target (§4.2). A divergence means a target forked the bundle.
1732        assert_eq!(
1733            webr_cm, pyodide_cm,
1734            "codemirror.js must be byte-identical across targets (shared, not forked)"
1735        );
1736
1737        // Clause 3: the bundle is a real CM6 build, not an empty/comment-only stub.
1738        // CM6 core + lang-r + lang-python is ~150-250KB minified; a 23-byte stub
1739        // fails here.
1740        assert!(
1741            webr_cm.len() > 10_000,
1742            "codemirror.js must be a real bundle (>10_000 bytes), got {} bytes",
1743            webr_cm.len()
1744        );
1745
1746        // Clause 4: EditorView — the CM6 core export. A wrong-library or UMD-global
1747        // bundle lacking the ESM EditorView export fails here.
1748        assert!(
1749            webr_cm.contains("EditorView"),
1750            "codemirror.js must contain EditorView (CM6 core ESM export)"
1751        );
1752
1753        // Clause 5: evidence of both language packs AND the UX-polish exports.
1754        // - `rLanguage` is the R language descriptor name (lang-r evidence —
1755        //   distinctive, preserved by esbuild; the bare export name `r` is too
1756        //   short to grep reliably).
1757        // - `python` is the lang-python export name (11 occurrences in the bundle).
1758        // - lineNumbers, highlightActiveLine, bracketMatching, indentWithTab are
1759        //   the UX-polish exports AC-3 consumes (AC-1 owns the complete export set).
1760        // - keymap is the keymap facet from @codemirror/view — AC-3 composes
1761        //   indentWithTab via keymap.of([indentWithTab]) so Tab stays in the
1762        //   editor (not browser focus traversal). Re-vendored by AC-3.
1763        // - syntaxHighlighting + defaultHighlightStyle are the token-styling
1764        //   exports from @codemirror/language — without these the editor renders
1765        //   text with no `.tok-*` classes (the builder-vision-probe regression:
1766        //   language support parses, highlight style colors).
1767        // - HighlightStyle + tags are the custom-style exports: HighlightStyle
1768        //   (from @codemirror/language) lets the runner define a HighlightStyle
1769        //   with deterministic `.tok-*` class names, and tags (from
1770        //   @lezer/highlight) supplies the tag constants the style maps. Without
1771        //   these the runner cannot override the default's opaque `ͼa` classes.
1772        // A core-only bundle missing the language packs, or a bundle missing the
1773        // UX/highlight exports, fails here.
1774        for needle in [
1775            "rLanguage",
1776            "python",
1777            "lineNumbers",
1778            "highlightActiveLine",
1779            "bracketMatching",
1780            "indentWithTab",
1781            "keymap",
1782            "syntaxHighlighting",
1783            "defaultHighlightStyle",
1784            "HighlightStyle",
1785            "tags",
1786        ] {
1787            assert!(
1788                webr_cm.contains(needle),
1789                "codemirror.js must contain `{needle}` (language pack or UX export evidence)"
1790            );
1791        }
1792
1793        // Clause 6: no web workers — CM6 must be main-thread-only to avoid COOP/COEP
1794        // conflicts. A bundler-emitted `new Worker(` (e.g. a future WASM-parser
1795        // extension) fails here.
1796        assert!(
1797            !webr_cm.contains("new Worker("),
1798            "codemirror.js must not contain `new Worker(` (COOP/COEP isolation)"
1799        );
1800
1801        // Clause 7: full positional invariant — codemirror.js sits after styles.css
1802        // AND before any lessons/ file in the deterministic files vector. This is
1803        // the sufficient condition (not the weak "after styles.css" proxy): a rule
1804        // appended after codemirror.js but before lessons/ would still pass the weak
1805        // proxy but break the intended order. Checking both bounds pins the full
1806        // invariant.
1807        let webr_files = webr.files();
1808        let pos = |name: &str| -> usize {
1809            webr_files
1810                .iter()
1811                .position(|f| f.path == Path::new(name))
1812                .unwrap_or_else(|| panic!("{name} must be in the planned site"))
1813        };
1814        let cm_pos = pos("codemirror.js");
1815        let styles_pos = pos("styles.css");
1816        let first_lesson_pos = webr_files
1817            .iter()
1818            .position(|f| f.path.starts_with("lessons/"))
1819            .expect("at least one lessons/ file exists");
1820        assert!(
1821            cm_pos > styles_pos,
1822            "codemirror.js (index {cm_pos}) must come after styles.css (index {styles_pos})"
1823        );
1824        assert!(
1825            cm_pos < first_lesson_pos,
1826            "codemirror.js (index {cm_pos}) must come before the first lessons/ file \
1827             (index {first_lesson_pos})"
1828        );
1829
1830        // Clause 8: CODEMIRROR_JS const compiles — proved by the test running at
1831        // all. include_str! refuses a missing asset at compile time (§1.3.1), so
1832        // reaching this assertion means the const resolved. No runtime check needed;
1833        // the test's existence is the proof.
1834    }
1835
1836    #[test]
1837    fn plan_site_shells_load_codemirror_as_import() {
1838        // AC-2 (code-editor): the lesson runner core wires a CodeMirror 6 editor
1839        // into the submission mount via a STATIC ESM import from the vendored
1840        // codemirror.js bundle (AC-1), reads the doc back through a
1841        // `getSubmission()` contract (not `.value`), never touches `innerHTML`
1842        // on the editor DOM, and each target adapter declares its `language`
1843        // as a dedicated field (not a string match on `runtime.name`).
1844        //
1845        // 6 clauses pin the build-time invariant (§1.5):
1846        //   3. data-test="submission" + id="submission" preserved on the div;
1847        //      the runSubmission contract on window.__bt is unchanged.
1848        //   4. lesson-runner-core.js has a static `import { EditorView ... }`
1849        //      from "./codemirror.js" (not a dynamic import, not a global).
1850        //   5. feedback.js reads the submission via `getSubmission()`, never
1851        //      `.value` on a submission element (the old textarea proxy).
1852        //   6. lesson-runner-core.js never uses `innerHTML` on the editor DOM
1853        //      (untrusted code_template must never be parsed as HTML).
1854        //   7. both lesson-runner.js adapters pass a `language:` field (closed
1855        //      set: "r" | "python"), not a runtime.name string match.
1856        // (Clause 1 — codemirror.js in site output — is pinned by
1857        //  plan_site_emits_vendored_codemirror_bundle; clause 2 — div not
1858        //  textarea — is pinned in plan_site_shells_contain_semantic_regions.)
1859        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1860        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1861
1862        // Clause 3: the rodney test contract survives the textarea→div swap. The
1863        // data-test hook and id are still on the submission mount, and the
1864        // window.__bt.runSubmission seam is still exported by the runner core.
1865        for (target, site) in [(BuildTarget::Webr, &webr), (BuildTarget::Pyodide, &pyodide)] {
1866            let html = &file(site, "index.html").contents;
1867            assert!(
1868                html.contains(r#"data-test="submission""#),
1869                "{target}: data-test=\"submission\" hook must be preserved on the div"
1870            );
1871            assert!(
1872                html.contains(r#"id="submission""#),
1873                "{target}: id=\"submission\" must be preserved on the div"
1874            );
1875        }
1876        let core = &file(&webr, "lesson-runner-core.js").contents;
1877        assert!(
1878            core.contains("runSubmission"),
1879            "lesson-runner-core.js must still export the runSubmission contract"
1880        );
1881        assert!(
1882            core.contains("window.__bt"),
1883            "lesson-runner-core.js must still expose window.__bt"
1884        );
1885
1886        // Clause 4: static ESM import of EditorView from the vendored bundle. A
1887        // dynamic import (`import("./codemirror.js")`) or a UMD global fails here
1888        // — the static import is what makes the editor a build-time dependency.
1889        assert!(
1890            core.contains("import { EditorView"),
1891            "lesson-runner-core.js must statically import EditorView from codemirror.js"
1892        );
1893        assert!(
1894            core.contains("\"./codemirror.js\""),
1895            "lesson-runner-core.js must import from \"./codemirror.js\""
1896        );
1897
1898        // Clause 5: feedback.js reads the submission through the getSubmission()
1899        // contract, never the old `.value` textarea proxy. A feedback.js that
1900        // still reads `submissionEl.value` or `getElementById(\"submission\").value`
1901        // fails here — the div has no `.value`, so that path returns undefined.
1902        let feedback = &file(&webr, "feedback.js").contents;
1903        assert!(
1904            feedback.contains("getSubmission"),
1905            "feedback.js must read the submission via getSubmission()"
1906        );
1907        assert!(
1908            !feedback.contains("submissionEl.value"),
1909            "feedback.js must NOT read submissionEl.value (the div has no .value)"
1910        );
1911        assert!(
1912            !feedback.contains("getElementById(\"submission\").value"),
1913            "feedback.js must NOT read .value on the submission element"
1914        );
1915
1916        // Clause 6: no innerHTML on the editor DOM. The code_template is untrusted
1917        // lesson content; parsing it as HTML would be an injection vector (the
1918        // same threat model that keeps lesson titles off innerHTML). The runner
1919        // core must use EditorView.dispatch / textContent, never innerHTML.
1920        assert!(
1921            !core.contains("innerHTML"),
1922            "lesson-runner-core.js must never use innerHTML (untrusted code_template)"
1923        );
1924
1925        // Clause 7: each target adapter declares a `language:` field (closed set),
1926        // not a string match on runtime.name. A webr adapter with `language: \"r\"`
1927        // and a pyodide adapter with `language: \"python\"` pass; an adapter that
1928        // omits the field (relying on runtime.name matching) fails here.
1929        let webr_runner = &file(&webr, "lesson-runner.js").contents;
1930        let pyodide_runner = &file(&pyodide, "lesson-runner.js").contents;
1931        assert!(
1932            webr_runner.contains("language: \"r\""),
1933            "webr/lesson-runner.js must declare language: \"r\""
1934        );
1935        assert!(
1936            pyodide_runner.contains("language: \"python\""),
1937            "pyodide/lesson-runner.js must declare language: \"python\""
1938        );
1939    }
1940
1941    #[test]
1942    fn plan_site_cm6_editor_ux_extensions_configured() {
1943        // AC-3 (code-editor): the CM6 editor is configured with standard
1944        // code-editor UX — line numbers, bracket matching, smart tab handling,
1945        // active line highlighting, spellcheck off. This test pins the 5
1946        // build-time-checkable clauses (1, 5, 8, 10, 11) of the executable spec
1947        // by parsing the assembled SiteFiles content. The rodney browser probes
1948        // (clauses 2/3/4/6/7/9/12) are run by @builder-vision-probe — they need
1949        // a live browser + CM6 boot to check computed styles.
1950        //
1951        // 8 clauses pin the build-time invariant (§1.5 — predicates, not
1952        // coincident shape):
1953        //   1. lineNumbers() wired into the editor extensions
1954        //   2. highlightActiveLine() wired
1955        //   3. bracketMatching() wired
1956        //   4. indentWithTab in a keymap.of([...]) composition (Tab stays in
1957        //      editor, not browser focus traversal — sneaky-pass #3)
1958        //   5. spellcheck explicitly set to "false" (not absent — absent
1959        //      inherits true on contenteditable, sneaky-pass #5)
1960        //   6. spellcheck never set to "true"
1961        //   7. .cm-matchingBracket CSS rule has a non-transparent visual property
1962        //      (background/outline/box-shadow/border-color — sneaky-pass #2:
1963        //      bracketMatching() adds the class but no CSS = no visual)
1964        //   8. .cm-gutters not hidden via display:none/visibility:hidden
1965        //      (sneaky-pass #1: gutter exists but zero pixels)
1966        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1967
1968        let core = &file(&webr, "lesson-runner-core.js").contents;
1969        let css = &file(&webr, "styles.css").contents;
1970
1971        // Clause 1: lineNumbers() — the gutter extension is wired. A config
1972        // that imports lineNumbers but forgets to add it to the extensions
1973        // array fails here.
1974        assert!(
1975            core.contains("lineNumbers()"),
1976            "lesson-runner-core.js must wire lineNumbers() into the editor extensions"
1977        );
1978
1979        // Clause 2: highlightActiveLine() — active-line highlighting wired.
1980        assert!(
1981            core.contains("highlightActiveLine()"),
1982            "lesson-runner-core.js must wire highlightActiveLine() into the editor extensions"
1983        );
1984
1985        // Clause 3: bracketMatching() — bracket-match highlighting wired.
1986        assert!(
1987            core.contains("bracketMatching()"),
1988            "lesson-runner-core.js must wire bracketMatching() into the editor extensions"
1989        );
1990
1991        // Clause 4: indentWithTab in a keymap.of([...]) composition. Importing
1992        // indentWithTab alone is insufficient — it must be passed to keymap.of
1993        // so the Tab key is intercepted by the editor (sneaky-pass #3: imported
1994        // but not composed, Tab falls through to browser focus traversal).
1995        assert!(
1996            core.contains("indentWithTab"),
1997            "lesson-runner-core.js must reference indentWithTab"
1998        );
1999        assert!(
2000            core.contains("keymap.of("),
2001            "lesson-runner-core.js must compose indentWithTab via keymap.of([...])"
2002        );
2003
2004        // Clause 5: spellcheck explicitly set to "false". The contenteditable
2005        // .cm-content inherits spellcheck=true by browser default; an absent
2006        // attribute is NOT sufficient (sneaky-pass #5). The explicit "false"
2007        // string must appear alongside spellcheck.
2008        assert!(
2009            core.contains("spellcheck"),
2010            "lesson-runner-core.js must set spellcheck on the editor content"
2011        );
2012        assert!(
2013            core.contains("\"false\""),
2014            "lesson-runner-core.js must set spellcheck to \"false\" (explicit, not absent)"
2015        );
2016
2017        // Clause 6: spellcheck never set to "true". A config that accidentally
2018        // enables spellcheck fails here.
2019        assert!(
2020            !core.contains("spellcheck: \"true\""),
2021            "lesson-runner-core.js must NOT set spellcheck to \"true\""
2022        );
2023        assert!(
2024            !core.contains("spellcheck:'true'"),
2025            "lesson-runner-core.js must NOT set spellcheck to 'true'"
2026        );
2027
2028        // Clause 7: .cm-matchingBracket CSS rule has a non-transparent visual
2029        // property. bracketMatching() adds the cm-matchingBracket class to the
2030        // DOM, but without a CSS rule the class is invisible (sneaky-pass #2).
2031        // The rule must set background/outline/box-shadow/border-color to a
2032        // non-transparent value. We check the rule exists AND contains one of
2033        // the visual properties (a bare `.cm-matchingBracket {}` empty rule fails).
2034        assert!(
2035            css.contains(".cm-matchingBracket"),
2036            "styles.css must contain a .cm-matchingBracket rule"
2037        );
2038        // Extract the .cm-matchingBracket block and verify it has a visual
2039        // property. A transparent-only rule (e.g. background: transparent) is
2040        // insufficient — the bracket match must be VISIBLE.
2041        let bm_pos = css.find(".cm-matchingBracket").expect("bracket-match rule");
2042        let after_bm = &css[bm_pos..];
2043        let brace = after_bm
2044            .find('{')
2045            .expect(".cm-matchingBracket must be followed by a declaration block");
2046        let body_start = bm_pos + brace + 1;
2047        let body_slice = &css[body_start..];
2048        let close = body_slice
2049            .find('}')
2050            .expect(".cm-matchingBracket block must close");
2051        let bm_body = &css[body_start..body_start + close];
2052        assert!(
2053            bm_body.contains("background")
2054                || bm_body.contains("outline")
2055                || bm_body.contains("box-shadow")
2056                || bm_body.contains("border-color"),
2057            ".cm-matchingBracket must have a visual property (background/outline/box-shadow/border-color)"
2058        );
2059        assert!(
2060            !bm_body.contains("transparent"),
2061            ".cm-matchingBracket visual property must NOT be transparent"
2062        );
2063
2064        // Clause 8: .cm-gutters not hidden. A rule that sets display:none or
2065        // visibility:hidden on .cm-gutters makes the gutter zero-pixels
2066        // (sneaky-pass #1: gutter exists in DOM but is invisible).
2067        assert!(
2068            !css.contains(".cm-gutters") || !gutter_hidden(css),
2069            "styles.css must NOT hide .cm-gutters via display:none or visibility:hidden"
2070        );
2071    }
2072
2073    #[test]
2074    fn plan_site_runner_core_sets_cursor_color_via_cm6_theme() {
2075        // v4 cursor fix: CSS-only overrides in styles.css (!important +
2076        // caret-color, PRs #93/#94) did not work in the live browser despite
2077        // correct CSS on the deployed site. CM6's injected base-theme styles
2078        // were winning the cascade. The fix moves cursor styling into a CM6
2079        // EditorView.theme() extension, which injects styles via CM6's own
2080        // style-module system at a higher precedence than the base theme.
2081        //
2082        // 7 clauses pin the fix:
2083        //   1. EditorView.theme() is called to create a theme extension
2084        //   2. The theme targets .cm-cursor with borderLeftColor
2085        //   3. The borderLeftColor uses var(--bt-color-cursor) (adapts to
2086        //      light/dark via the CSS variable defined in styles.css)
2087        //   4. The theme sets borderLeftWidth: 2px (wider than CM6 default)
2088        //   5. The theme sets marginLeft: -1px (centers the wider cursor)
2089        //   6. The theme targets .cm-content with caretColor (native caret)
2090        //   7. The theme extension is added to the editorExtensions array
2091        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2092        let core = &file(&webr, "lesson-runner-core.js").contents;
2093
2094        // Clause 1: EditorView.theme() is called — creates a CM6 theme
2095        // extension rather than relying on external CSS.
2096        assert!(
2097            core.contains("EditorView.theme("),
2098            "lesson-runner-core.js must call EditorView.theme() to create a \
2099             cursor theme extension (CSS-only overrides did not work in live \
2100             browser — CM6 base-theme styles won the cascade)"
2101        );
2102
2103        // Clause 2: the theme targets .cm-cursor with borderLeftColor.
2104        // CM6's base theme sets borderLeft: "1.2px solid black" — we must
2105        // override the borderLeftColor to a light value for dark mode.
2106        assert!(
2107            core.contains("borderLeftColor"),
2108            "lesson-runner-core.js cursor theme must set borderLeftColor on \
2109             .cm-cursor (CM6 default is black — invisible on dark background)"
2110        );
2111
2112        // Clause 3: borderLeftColor uses var(--bt-color-cursor) so the cursor
2113        // adapts to light/dark mode via the CSS variable defined in
2114        // styles.css (light: #1a1a1a, dark: #ffffff). The fallback #ffffff
2115        // ensures visibility if the variable is undefined.
2116        assert!(
2117            core.contains("var(--bt-color-cursor, #ffffff)"),
2118            "lesson-runner-core.js cursor theme must use \
2119             var(--bt-color-cursor, #ffffff) for borderLeftColor \
2120             (adapts to light/dark via CSS variable, fallback ensures \
2121             visibility)"
2122        );
2123
2124        // Clause 4: cursor width is 2px (wider than CM6 default 1.2px).
2125        assert!(
2126            core.contains("borderLeftWidth") && core.contains("\"2px\""),
2127            "lesson-runner-core.js cursor theme must set borderLeftWidth: \
2128             \"2px\" (CM6 default 1.2px is too faint — widened for \
2129             accessibility)"
2130        );
2131
2132        // Clause 5: the theme sets marginLeft to center the wider cursor on
2133        // the insertion point. CM6's default cursor is 1.2px with
2134        // marginLeft: -0.6px; at 2px width the cursor must shift to -1px
2135        // to stay centered.
2136        assert!(
2137            core.contains("marginLeft"),
2138            "lesson-runner-core.js cursor theme must set marginLeft for centering"
2139        );
2140
2141        // Clause 6: the theme targets .cm-content with caretColor. The
2142        // native contenteditable caret renders as faint gray in dark mode;
2143        // setting caretColor makes it match the drawn cursor.
2144        assert!(
2145            core.contains("caretColor"),
2146            "lesson-runner-core.js cursor theme must set caretColor on \
2147             .cm-content (native contenteditable caret is faint gray \
2148             without it)"
2149        );
2150
2151        // Clause 7: the theme extension is added to the editorExtensions
2152        // array. A theme defined but not wired into the extensions array
2153        // would have no effect (sneaky-pass: defined but not composed).
2154        assert!(
2155            core.contains("cursorTheme"),
2156            "lesson-runner-core.js must add cursorTheme to the \
2157             editorExtensions array (a theme defined but not wired has no \
2158             effect)"
2159        );
2160    }
2161
2162    #[test]
2163    fn plan_site_runner_core_renders_hints_as_expandable_details() {
2164        // The lesson runner core must render `lesson.hints` as an expandable
2165        // <details> element when the hints field is non-null and non-empty,
2166        // and remove it when switching to a lesson without hints.
2167        //
2168        // Build-time-checkable invariants (the rodney browser probe verifies the
2169        // live DOM behavior):
2170        //   1. References `lesson.hints` — the field is consumed, not ignored.
2171        //   2. Creates a <details> element with id="lesson-hints".
2172        //   3. Creates a <summary> child (the expandable toggle).
2173        //   4. Sets hints text via textContent — NEVER parsed as HTML (the same
2174        //      threat model that keeps lesson titles off innerHTML; untrusted
2175        //      lesson content must never be parsed as HTML).
2176        //   5. Inserts the details after #lesson-prompt (the prompt element).
2177        //   6. Removes the element when hints is null/empty (lesson switch).
2178        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2179        let core = &file(&webr, "lesson-runner-core.js").contents;
2180
2181        // Clause 1: the hints field is read from the lesson object.
2182        assert!(
2183            core.contains("lesson.hints"),
2184            "lesson-runner-core.js must reference lesson.hints"
2185        );
2186
2187        // Clause 2: a <details> element with id="lesson-hints" is created.
2188        assert!(
2189            core.contains(r#""details""#),
2190            "lesson-runner-core.js must create a <details> element"
2191        );
2192        assert!(
2193            core.contains(r#""lesson-hints""#),
2194            "lesson-runner-core.js must set id=\"lesson-hints\" on the details"
2195        );
2196
2197        // Clause 3: a <summary> child is created.
2198        assert!(
2199            core.contains(r#""summary""#),
2200            "lesson-runner-core.js must create a <summary> element"
2201        );
2202
2203        // Clause 4: hints text is set via textContent. The existing
2204        // `!core.contains("innerHTML")` invariant (plan_site_shells_load_codemirror_as_import
2205        // clause 6) already guarantees no innerHTML is used anywhere — so the
2206        // hints text MUST go through textContent. We assert textContent appears
2207        // in the hints-rendering path by checking it is used at all (the existing
2208        // invariant covers the negative case).
2209        assert!(
2210            core.contains("textContent"),
2211            "lesson-runner-core.js must use textContent for hints text"
2212        );
2213
2214        // Clause 5: the details is inserted after the prompt element. The runner
2215        // core holds a `promptEl` reference (document.getElementById("lesson-prompt"));
2216        // the hints details must be inserted relative to it.
2217        assert!(
2218            core.contains("promptEl"),
2219            "lesson-runner-core.js must reference promptEl for hints insertion"
2220        );
2221
2222        // Clause 6: the element is removed when hints is absent. A renderLesson
2223        // that only creates but never removes would leave a stale hints panel
2224        // from a previous lesson visible after switching.
2225        assert!(
2226            core.contains(".remove()"),
2227            "lesson-runner-core.js must remove the hints element when absent"
2228        );
2229    }
2230
2231    #[test]
2232    fn plan_site_styles_css_has_hints_panel_rules() {
2233        // The #lesson-hints <details> panel must be styled via var(--bt-*)
2234        // tokens, with a pointer cursor on the <summary> toggle. Dark mode is
2235        // handled by the existing :root token overrides in the @media block —
2236        // no hardcoded hex literals in the hints rules.
2237        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2238        let css = &file(&webr, "styles.css").contents;
2239
2240        // Clause 1: #lesson-hints selector exists.
2241        assert!(
2242            css.contains("#lesson-hints"),
2243            "styles.css must contain a #lesson-hints rule"
2244        );
2245
2246        // Clause 2: the summary toggle has cursor: pointer.
2247        let hints_pos = css
2248            .find("#lesson-hints")
2249            .expect("#lesson-hints rule exists");
2250        let after_hints = &css[hints_pos..];
2251        assert!(
2252            after_hints.contains("cursor: pointer") || after_hints.contains("cursor:pointer"),
2253            "styles.css must set cursor: pointer on the hints summary toggle"
2254        );
2255
2256        // Clause 3: the hints rules use var(--bt-*) tokens (no hardcoded hex).
2257        // Extract the #lesson-hints rule block and check for token usage.
2258        let brace = after_hints
2259            .find('{')
2260            .expect("#lesson-hints must have an opening brace");
2261        let body_start = hints_pos + brace + 1;
2262        let body_slice = &css[body_start..];
2263        let close = body_slice
2264            .find('}')
2265            .expect("#lesson-hints block must close");
2266        let hints_body = &css[body_start..body_start + close];
2267        assert!(
2268            hints_body.contains("var(--bt-"),
2269            "#lesson-hints must use var(--bt-*) tokens, got: {hints_body}"
2270        );
2271    }
2272
2273    #[test]
2274    fn plan_site_runner_core_splits_hints_and_gotchas() {
2275        // AC-2 (issue #88): Split hints and gotchas into separate expandable UI
2276        // sections. The lesson runner core must have TWO separate functions —
2277        // renderHints and renderGotchas — each creating its own <details> with a
2278        // unique ID, parsing bullet lines into <ul><li>, using textContent (never
2279        // innerHTML), and independently removing stale DOM. renderLesson must
2280        // call both. The combined "Hints & Gotchas" panel is gone.
2281        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2282        let core = &file(&webr, "lesson-runner-core.js").contents;
2283
2284        // Clause 1: renderGotchas function exists (new — the split).
2285        assert!(
2286            core.contains("function renderGotchas("),
2287            "lesson-runner-core.js must define a renderGotchas function"
2288        );
2289
2290        // Clause 2: renderHints function still exists (now hints-only).
2291        assert!(
2292            core.contains("function renderHints("),
2293            "lesson-runner-core.js must define a renderHints function"
2294        );
2295
2296        // Clause 3: lesson.gotchas is referenced (the field is consumed).
2297        assert!(
2298            core.contains("lesson.gotchas"),
2299            "lesson-runner-core.js must reference lesson.gotchas"
2300        );
2301
2302        // Clause 4: unique ID "lesson-gotchas" for the gotchas details.
2303        assert!(
2304            core.contains(r#""lesson-gotchas""#),
2305            "lesson-runner-core.js must set id=\"lesson-gotchas\" on the gotchas details"
2306        );
2307
2308        // Clause 5: the combined "Hints & Gotchas" label is gone.
2309        assert!(
2310            !core.contains("Hints & Gotchas"),
2311            "lesson-runner-core.js must NOT use the combined 'Hints & Gotchas' label"
2312        );
2313
2314        // Clause 6: bullet parsing creates <ul> and <li> elements.
2315        assert!(
2316            core.contains(r#""ul""#),
2317            "lesson-runner-core.js must create <ul> elements for bullet parsing"
2318        );
2319        assert!(
2320            core.contains(r#""li""#),
2321            "lesson-runner-core.js must create <li> elements for bullet parsing"
2322        );
2323
2324        // Clause 7: bullet marker detection uses startsWith (for "- " or "* ").
2325        assert!(
2326            core.contains("startsWith"),
2327            "lesson-runner-core.js must use startsWith for bullet marker detection"
2328        );
2329
2330        // Clause 8: textContent is used for <li> text (not innerHTML).
2331        // The existing !innerHTML invariant (plan_site_shells_load_codemirror_as_import
2332        // clause 6) covers the negative case.
2333        assert!(
2334            core.contains("textContent"),
2335            "lesson-runner-core.js must use textContent for bullet text"
2336        );
2337
2338        // Clause 9: each function independently removes stale DOM (>= 2 .remove()).
2339        let remove_count = core.matches(".remove()").count();
2340        assert!(
2341            remove_count >= 2,
2342            "lesson-runner-core.js must have >= 2 .remove() calls (one per section), got {remove_count}"
2343        );
2344
2345        // Clause 10: renderLesson calls both renderHints and renderGotchas.
2346        assert!(
2347            core.contains("renderHints(lesson.hints)"),
2348            "renderLesson must call renderHints(lesson.hints)"
2349        );
2350        assert!(
2351            core.contains("renderGotchas(lesson.gotchas)"),
2352            "renderLesson must call renderGotchas(lesson.gotchas)"
2353        );
2354    }
2355
2356    #[test]
2357    fn plan_site_styles_css_has_gotchas_panel_rules() {
2358        // AC-2 (issue #88): The #lesson-gotchas <details> panel must be styled
2359        // via var(--bt-*) tokens, parallel to #lesson-hints. Dark mode is
2360        // handled by the existing :root token overrides in the @media block —
2361        // no hardcoded hex literals in the gotchas rules.
2362        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2363        let css = &file(&webr, "styles.css").contents;
2364
2365        // Clause 1: #lesson-gotchas selector exists.
2366        assert!(
2367            css.contains("#lesson-gotchas"),
2368            "styles.css must contain a #lesson-gotchas rule"
2369        );
2370
2371        // Clause 2: the gotchas summary toggle has cursor: pointer.
2372        let gotchas_pos = css
2373            .find("#lesson-gotchas")
2374            .expect("#lesson-gotchas rule exists");
2375        let after_gotchas = &css[gotchas_pos..];
2376        assert!(
2377            after_gotchas.contains("cursor: pointer") || after_gotchas.contains("cursor:pointer"),
2378            "styles.css must set cursor: pointer on the gotchas summary toggle"
2379        );
2380
2381        // Clause 3: the gotchas rules use var(--bt-*) tokens (no hardcoded hex).
2382        let brace = after_gotchas
2383            .find('{')
2384            .expect("#lesson-gotchas must have an opening brace");
2385        let body_start = gotchas_pos + brace + 1;
2386        let body_slice = &css[body_start..];
2387        let close = body_slice
2388            .find('}')
2389            .expect("#lesson-gotchas block must close");
2390        let gotchas_body = &css[body_start..body_start + close];
2391        assert!(
2392            gotchas_body.contains("var(--bt-"),
2393            "#lesson-gotchas must use var(--bt-*) tokens, got: {gotchas_body}"
2394        );
2395    }
2396
2397    #[test]
2398    fn plan_site_emits_csp_sri_and_referrer_policy() {
2399        // AC-1 (issue #96): CSP + SRI + Referrer Policy on all built sites.
2400        // Free hardening: a Content-Security-Policy meta tag restricts what
2401        // origins may serve scripts/styles/connections, SRI pins CDN scripts to
2402        // a known hash, and a referrer policy prevents leaking the site URL to
2403        // cross-origin LLM providers.
2404        //
2405        // 11 clauses pin the security envelope (§1.5 — predicates, not
2406        // coincident shape):
2407        //   1. Exactly one CSP meta tag in head, before first script tag
2408        //   2. CSP content = single CSP_POLICY const (no wildcard schemes in
2409        //      script-src: no https:, http:, *, 'unsafe-inline')
2410        //   3. script-src includes both CDN origins (cdn.jsdelivr.net +
2411        //      webr.r-wasm.org) in BOTH targets
2412        //   4. connect-src includes self, cdn.jsdelivr.net, webr.r-wasm.org,
2413        //      repo.r-wasm.org, api.fireworks.ai, api.anthropic.com
2414        //   5. style-src includes 'unsafe-inline' (CM6 StyleModule injects
2415        //      style tags)
2416        //   6. worker-src includes 'self' blob: (WebR channel workers)
2417        //   7. Exactly one referrer meta tag, content="no-referrer"
2418        //   8. Pyodide CDN script has non-empty integrity (sha256/384/512
2419        //      prefix) + crossorigin="anonymous"
2420        //   9. WebR target: NO integrity attributes on any HTML tag (ES module
2421        //      import can't have SRI — gap accepted, documented)
2422        //   10. eval-results.html has same CSP + referrer meta
2423        //   11. CSP_POLICY defined as single const in site/mod.rs
2424
2425        // Clause 11: CSP_POLICY is a single const — referencing it here proves
2426        // it exists at compile time. A CSP string duplicated per target would
2427        // not have a single named source the test can pin.
2428        let csp = CSP_POLICY;
2429        assert!(
2430            !csp.is_empty(),
2431            "CSP_POLICY must be a non-empty const, not a placeholder"
2432        );
2433
2434        for (target, course) in [
2435            (BuildTarget::Webr, r_course()),
2436            (BuildTarget::Pyodide, python_course()),
2437        ] {
2438            let site = plan(&course, target).expect("plans");
2439            let html = &file(&site, "index.html").contents;
2440
2441            // --- Clause 1: exactly one CSP meta tag in head, before first script
2442            let csp_meta = r#"<meta http-equiv="Content-Security-Policy""#;
2443            let csp_count = html.matches(csp_meta).count();
2444            assert_eq!(
2445                csp_count, 1,
2446                "{target}: expected exactly one CSP meta tag, got {csp_count}"
2447            );
2448            let csp_pos = html.find(csp_meta).expect("CSP meta exists");
2449            let head_close = html.find("</head>").expect("head closes");
2450            assert!(
2451                csp_pos < head_close,
2452                "{target}: CSP meta must be inside <head>"
2453            );
2454            let first_script_pos = html.find("<script").expect("at least one script tag");
2455            assert!(
2456                csp_pos < first_script_pos,
2457                "{target}: CSP meta must appear before first <script> tag"
2458            );
2459
2460            // --- Clause 2: CSP content matches CSP_POLICY const, no wildcards
2461            let csp_content = extract_meta_content(html, "Content-Security-Policy")
2462                .expect("CSP content attribute is present");
2463            assert_eq!(
2464                csp_content, csp,
2465                "{target}: CSP content must match CSP_POLICY const exactly"
2466            );
2467            let script_src = extract_csp_directive(&csp_content, "script-src");
2468            assert!(
2469                !script_src.is_empty(),
2470                "{target}: CSP must have a script-src directive"
2471            );
2472            let script_sources: Vec<&str> = script_src.split_whitespace().collect();
2473            for wildcard in ["https:", "http:", "*", "'unsafe-inline'"] {
2474                assert!(
2475                    !script_sources.contains(&wildcard),
2476                    "{target}: script-src must not contain wildcard `{wildcard}`, \
2477                     sources: {script_src}"
2478                );
2479            }
2480
2481            // --- Clause 3: script-src includes both CDN origins in BOTH targets
2482            assert!(
2483                script_src.contains("https://cdn.jsdelivr.net"),
2484                "{target}: script-src must include cdn.jsdelivr.net"
2485            );
2486            assert!(
2487                script_src.contains("https://webr.r-wasm.org"),
2488                "{target}: script-src must include webr.r-wasm.org"
2489            );
2490
2491            // --- Clause 4: connect-src includes all required origins
2492            let connect_src = extract_csp_directive(&csp_content, "connect-src");
2493            for origin in [
2494                "https://cdn.jsdelivr.net",
2495                "https://webr.r-wasm.org",
2496                "https://repo.r-wasm.org",
2497                "https://api.fireworks.ai",
2498                "https://api.anthropic.com",
2499            ] {
2500                assert!(
2501                    connect_src.contains(origin),
2502                    "{target}: connect-src must include {origin}, got: {connect_src}"
2503                );
2504            }
2505
2506            // --- Clause 5: style-src includes 'unsafe-inline'
2507            let style_src = extract_csp_directive(&csp_content, "style-src");
2508            assert!(
2509                style_src.contains("'unsafe-inline'"),
2510                "{target}: style-src must include 'unsafe-inline' \
2511                 (CM6 StyleModule injects style tags), got: {style_src}"
2512            );
2513
2514            // --- Clause 6: worker-src includes 'self' blob:
2515            let worker_src = extract_csp_directive(&csp_content, "worker-src");
2516            assert!(
2517                worker_src.contains("'self'") && worker_src.contains("blob:"),
2518                "{target}: worker-src must include 'self' blob: \
2519                 (WebR channel workers), got: {worker_src}"
2520            );
2521
2522            // --- Clause 7: exactly one referrer meta, content="no-referrer"
2523            let referrer_meta = r#"<meta name="referrer""#;
2524            let referrer_count = html.matches(referrer_meta).count();
2525            assert_eq!(
2526                referrer_count, 1,
2527                "{target}: expected exactly one referrer meta tag, got {referrer_count}"
2528            );
2529            let referrer_content =
2530                extract_meta_content(html, "referrer").expect("referrer content");
2531            assert_eq!(
2532                referrer_content, "no-referrer",
2533                "{target}: referrer meta must have content=\"no-referrer\""
2534            );
2535            let referrer_pos = html.find(referrer_meta).expect("referrer meta exists");
2536            assert!(
2537                referrer_pos < first_script_pos,
2538                "{target}: referrer meta must appear before first <script> tag"
2539            );
2540
2541            // --- Clauses 8/9: SRI per target
2542            match target {
2543                BuildTarget::Pyodide => {
2544                    // Clause 8: Pyodide CDN script has non-empty integrity +
2545                    // crossorigin="anonymous"
2546                    let cdn_pos = html
2547                        .find(r#"src="https://cdn.jsdelivr.net/pyodide"#)
2548                        .expect("pyodide CDN script exists");
2549                    // Find the closing > of this script tag
2550                    let tag_end = html[cdn_pos..]
2551                        .find('>')
2552                        .map(|p| cdn_pos + p)
2553                        .expect("pyodide CDN script tag closes");
2554                    let script_tag = &html[cdn_pos..=tag_end];
2555
2556                    // integrity attribute with sha256/384/512 prefix
2557                    let integrity = extract_attr_value(script_tag, "integrity")
2558                        .expect("pyodide CDN script must have integrity attribute");
2559                    assert!(
2560                        integrity.starts_with("sha256-")
2561                            || integrity.starts_with("sha384-")
2562                            || integrity.starts_with("sha512-"),
2563                        "pyodide CDN integrity must start with sha256/384/512 prefix, \
2564                         got: {integrity}"
2565                    );
2566                    assert!(
2567                        integrity.len() > 10,
2568                        "pyodide CDN integrity must be non-empty (not just the prefix), \
2569                         got: {integrity}"
2570                    );
2571
2572                    // crossorigin="anonymous"
2573                    let crossorigin = extract_attr_value(script_tag, "crossorigin")
2574                        .expect("pyodide CDN script must have crossorigin attribute");
2575                    assert_eq!(
2576                        crossorigin, "anonymous",
2577                        "pyodide CDN script must have crossorigin=\"anonymous\""
2578                    );
2579                }
2580                BuildTarget::Webr => {
2581                    // Clause 9: WebR target has NO integrity attributes on any
2582                    // HTML tag. webR is loaded as an ES module import, which
2583                    // cannot carry an HTML integrity attribute — the SRI gap is
2584                    // accepted and documented in code comments.
2585                    assert!(
2586                        !html.contains("integrity="),
2587                        "webr index.html must NOT contain any integrity attributes \
2588                         (ES module import SRI gap — accepted, documented)"
2589                    );
2590                }
2591            }
2592
2593            // --- Clause 10: eval-results.html has same CSP + referrer meta
2594            let eval_html = &file(&site, "eval-results.html").contents;
2595            let eval_csp = extract_meta_content(eval_html, "Content-Security-Policy")
2596                .expect("eval-results.html must have CSP meta");
2597            assert_eq!(
2598                eval_csp, csp,
2599                "{target}: eval-results.html CSP must match CSP_POLICY const"
2600            );
2601            let eval_referrer =
2602                extract_meta_content(eval_html, "referrer").expect("eval-results referrer");
2603            assert_eq!(
2604                eval_referrer, "no-referrer",
2605                "{target}: eval-results.html must have referrer content=\"no-referrer\""
2606            );
2607        }
2608    }
2609
2610    /// Extract the `content` attribute value from a `<meta>` tag identified by
2611    /// its `http-equiv` or `name` attribute. Returns `None` if the tag or its
2612    /// content attribute is not found.
2613    fn extract_meta_content(html: &str, key: &str) -> Option<String> {
2614        for prefix in [format!(r#"http-equiv="{key}""#), format!(r#"name="{key}""#)] {
2615            if let Some(pos) = html.find(&prefix) {
2616                let after = &html[pos + prefix.len()..];
2617                if let Some(content_pos) = after.find(r#"content=""#) {
2618                    let start = content_pos + r#"content=""#.len();
2619                    let rest = &after[start..];
2620                    if let Some(end) = rest.find('"') {
2621                        return Some(rest[..end].to_string());
2622                    }
2623                }
2624            }
2625        }
2626        None
2627    }
2628
2629    /// Extract a CSP directive's value (the sources after the directive name,
2630    /// up to the next `;` or end of string).
2631    fn extract_csp_directive<'a>(csp: &'a str, directive: &str) -> &'a str {
2632        let needle = format!("{directive} ");
2633        if let Some(pos) = csp.find(&needle) {
2634            let start = pos + needle.len();
2635            let rest = &csp[start..];
2636            let end = rest.find(';').unwrap_or(rest.len());
2637            &rest[..end]
2638        } else {
2639            ""
2640        }
2641    }
2642
2643    /// Extract an attribute value from an HTML tag fragment. The `tag` slice
2644    /// should start at or before the attribute and end at or after the closing
2645    /// `>`. Returns `None` if the attribute is not found.
2646    fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
2647        let needle = format!(r#"{attr}=""#);
2648        let pos = tag.find(&needle)?;
2649        let start = pos + needle.len();
2650        let rest = &tag[start..];
2651        let end = rest.find('"')?;
2652        Some(rest[..end].to_string())
2653    }
2654
2655    /// Detect whether a `.cm-gutters` rule sets display:none or visibility:hidden.
2656    fn gutter_hidden(css: &str) -> bool {
2657        let mut rest = css;
2658        while let Some(pos) = rest.find(".cm-gutters") {
2659            let after = &rest[pos..];
2660            let Some(brace) = after.find('{') else {
2661                rest = &rest[pos + ".cm-gutters".len()..];
2662                continue;
2663            };
2664            let body_start = pos + brace + 1;
2665            let body_slice = &rest[body_start..];
2666            let Some(close) = body_slice.find('}') else {
2667                rest = &rest[pos + ".cm-gutters".len()..];
2668                continue;
2669            };
2670            let body = &rest[body_start..body_start + close];
2671            if body.contains("display: none") || body.contains("visibility: hidden") {
2672                return true;
2673            }
2674            rest = &rest[body_start + close..];
2675        }
2676        false
2677    }
2678
2679    // --- AC-4: client-side rate limiting — config.js emission (predicates 4-5) --
2680
2681    #[test]
2682    fn feedback_rate_limit_plan_site_emits_config_js() {
2683        // Predicate 4: plan_site emits config.js with "maxFeedbackPerSession": 5.
2684        // The config.js file is the Rust→JS contract for site-level configuration
2685        // (§3.2): it carries window.__btConfig = { maxFeedbackPerSession: N },
2686        // which feedback.js reads to enforce the per-session rate limit.
2687        let site_config = SiteConfig {
2688            max_feedback_per_session: 5,
2689        };
2690        let site = plan_site(
2691            &r_course(),
2692            BuildTarget::Webr,
2693            &EvalSummary::NotValidated,
2694            &site_config,
2695        )
2696        .expect("plans");
2697        let config = file(&site, "config.js");
2698        assert!(
2699            config.contents.contains("window.__btConfig"),
2700            "config.js must set window.__btConfig; got: {}",
2701            config.contents
2702        );
2703        assert!(
2704            config.contents.contains("maxFeedbackPerSession"),
2705            "config.js must contain maxFeedbackPerSession; got: {}",
2706            config.contents
2707        );
2708        assert!(
2709            config.contents.contains("maxFeedbackPerSession: 5"),
2710            "config.js must carry the configured max (5); got: {}",
2711            config.contents
2712        );
2713
2714        // The default SiteConfig (max=20) emits 20 — the default the feedback
2715        // rate limiter reads when no [site] section is present.
2716        let default_site = plan(&r_course(), BuildTarget::Webr).expect("plans");
2717        let default_config = file(&default_site, "config.js");
2718        assert!(
2719            default_config
2720                .contents
2721                .contains("maxFeedbackPerSession: 20"),
2722            "config.js must carry the default max (20); got: {}",
2723            default_config.contents
2724        );
2725
2726        // config.js is byte-identical across targets (shared contract, §4.2).
2727        let pyodide = plan_site(
2728            &python_course(),
2729            BuildTarget::Pyodide,
2730            &EvalSummary::NotValidated,
2731            &site_config,
2732        )
2733        .expect("plans");
2734        assert_eq!(
2735            file(&site, "config.js").contents,
2736            file(&pyodide, "config.js").contents,
2737            "config.js must be byte-identical across targets for the same SiteConfig"
2738        );
2739    }
2740
2741    #[test]
2742    fn feedback_rate_limit_shells_load_config_before_feedback() {
2743        // Predicate 5: both targets' index.html load config.js BEFORE feedback.js.
2744        // config.js sets window.__btConfig synchronously (classic script, not a
2745        // deferred module) so the global is available when feedback.js (a deferred
2746        // module) reads it. A shell that loads feedback.js first would leave
2747        // __btConfig undefined at rate-limit check time.
2748        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2749        let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
2750        for (target, site) in [(BuildTarget::Webr, &webr), (BuildTarget::Pyodide, &pyodide)] {
2751            let html = &file(site, "index.html").contents;
2752            let config_pos = html
2753                .find(r#"src="config.js""#)
2754                .unwrap_or_else(|| panic!("{target}: index.html must reference config.js"));
2755            let feedback_pos = html
2756                .find(r#"src="feedback.js""#)
2757                .unwrap_or_else(|| panic!("{target}: index.html must reference feedback.js"));
2758            assert!(
2759                config_pos < feedback_pos,
2760                "{target}: config.js must load before feedback.js (so __btConfig is set \
2761                 before the rate limiter reads it)"
2762            );
2763        }
2764    }
2765
2766    // --- AC-4: client-side rate limiting — feedback.js source scan (6,7) -------
2767
2768    #[test]
2769    fn feedback_rate_limit_feedback_js_has_rate_limiting() {
2770        // Predicate 6: feedback.js contains the rate-limiting patterns. No JS
2771        // harness exists, so this is a static source scan of the emitted feedback.js
2772        // (verification: code). 6 sub-clauses pin the invariant (§1.5):
2773        //   1. "bt_feedback_count" sessionStorage key
2774        //   2. window.__btConfig.maxFeedbackPerSession read
2775        //   3. limit-reached message string
2776        //   4. counter increment AFTER try/catch (failed requests count)
2777        //   5. textContent for limit message (NOT innerHTML — XSS defense)
2778        //   6. parseInt guard on stored counter
2779        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2780        let feedback = &file(&webr, "feedback.js").contents;
2781
2782        // Clause 1: the bt_feedback_count sessionStorage key.
2783        assert!(
2784            feedback.contains("bt_feedback_count"),
2785            "feedback.js must use the bt_feedback_count sessionStorage key; feedback={feedback}"
2786        );
2787
2788        // Clause 2: window.__btConfig.maxFeedbackPerSession read.
2789        assert!(
2790            feedback.contains("__btConfig"),
2791            "feedback.js must read window.__btConfig; feedback={feedback}"
2792        );
2793        assert!(
2794            feedback.contains("maxFeedbackPerSession"),
2795            "feedback.js must read maxFeedbackPerSession from __btConfig; feedback={feedback}"
2796        );
2797
2798        // Clause 3: a limit-reached message string (the user-facing copy).
2799        assert!(
2800            feedback.to_lowercase().contains("limit"),
2801            "feedback.js must contain a limit-reached message; feedback={feedback}"
2802        );
2803
2804        // Clause 5: textContent for the limit message (NOT innerHTML). The
2805        // existing !innerHTML invariant (plan_site_shells_load_codemirror_as_import
2806        // clause 6) already guarantees no innerHTML in lesson-runner-core.js;
2807        // here we pin that feedback.js also never uses .innerHTML (property
2808        // access) — the comment at line ~473 mentions `innerHTML` in backticks
2809        // (no dot), so checking `.innerHTML` catches only real property access.
2810        // This covers the limit message AND all other rendering (verdict,
2811        // error, pending) — feedback.js handles untrusted model output, so
2812        // innerHTML is never safe.
2813        assert!(
2814            feedback.contains("textContent"),
2815            "feedback.js must use textContent for the limit message; feedback={feedback}"
2816        );
2817        assert!(
2818            !feedback.contains(".innerHTML"),
2819            "feedback.js must NOT use .innerHTML (XSS defense — untrusted model output \
2820             and limit messages use textContent); feedback={feedback}"
2821        );
2822
2823        // Clause 6: parseInt guard on the stored counter. Without parseInt, a
2824        // corrupt or missing sessionStorage value yields a string comparison
2825        // (or NaN), silently disabling limiting. We check for `parseInt(`
2826        // (the call, with opening paren) rather than bare `parseInt` — the
2827        // word appears in explanatory comments but only the call enforces the
2828        // guard at runtime.
2829        assert!(
2830            feedback.contains("parseInt("),
2831            "feedback.js must call parseInt() to guard the stored counter; feedback={feedback}"
2832        );
2833
2834        // Clause 4: counter increment AFTER try/catch (failed requests count).
2835        // The increment call must NOT be inside the try block (which would skip
2836        // it on error) and must appear after the catch block's renderError call
2837        // (the last statement in the catch). We scope the search to handleSubmit
2838        // to avoid matching the try/catch in listModels.
2839        let handle_submit_pos = feedback
2840            .find("async function handleSubmit")
2841            .expect("feedback.js must define handleSubmit");
2842        let handle_submit_body = &feedback[handle_submit_pos..];
2843
2844        let try_pos = handle_submit_body
2845            .find("try {")
2846            .expect("handleSubmit must have a try block");
2847        let catch_pos = handle_submit_body
2848            .find("} catch (error) {")
2849            .expect("handleSubmit must have a catch block");
2850        let increment_pos = handle_submit_body
2851            .find("incrementFeedbackCount()")
2852            .expect("feedback.js must call incrementFeedbackCount in handleSubmit");
2853        let render_error_pos = handle_submit_body
2854            .find("renderError(container, error)")
2855            .expect("handleSubmit must call renderError in the catch block");
2856
2857        // The increment must NOT be inside the try block (between try { and } catch).
2858        assert!(
2859            !(increment_pos > try_pos && increment_pos < catch_pos),
2860            "incrementFeedbackCount must NOT be inside the try block \
2861             (failed requests count — the negative case: increment only inside try \
2862             skips failures); feedback={feedback}"
2863        );
2864        // The increment must be after renderError (the last statement in the catch),
2865        // so it is reached on both success and failure paths.
2866        assert!(
2867            increment_pos > render_error_pos,
2868            "incrementFeedbackCount must be called AFTER the try/catch block \
2869             (after renderError in the catch — both paths reach it); feedback={feedback}"
2870        );
2871    }
2872
2873    #[test]
2874    fn feedback_rate_limit_feedback_js_does_not_increment_around_list_models() {
2875        // Predicate 7: feedback.js does NOT increment the counter around
2876        // listModels (model discovery is not feedback — it doesn't count).
2877        // We verify the increment call does not appear in the listModels or
2878        // renderModelPicker function bodies.
2879        let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2880        let feedback = &file(&webr, "feedback.js").contents;
2881
2882        // Check listModels: find its body (from the function definition to the
2883        // next function definition) and assert no increment call within.
2884        let listmodels_start = feedback
2885            .find("async function listModels")
2886            .expect("feedback.js must define listModels");
2887        let after_listmodels = &feedback[listmodels_start..];
2888        let listmodels_end = after_listmodels[1..]
2889            .find("\nfunction ")
2890            .or_else(|| after_listmodels[1..].find("\nasync function "))
2891            .map(|p| p + 1)
2892            .unwrap_or(after_listmodels.len());
2893        let listmodels_body = &after_listmodels[..listmodels_end];
2894        assert!(
2895            !listmodels_body.contains("incrementFeedbackCount()"),
2896            "listModels must NOT call incrementFeedbackCount \
2897             (model discovery doesn't count); feedback={feedback}"
2898        );
2899
2900        // Check renderModelPicker: the function that calls listModels. The
2901        // increment must not appear here either — the guard and increment live
2902        // in handleSubmit, after the model picker phase.
2903        let picker_start = feedback
2904            .find("async function renderModelPicker")
2905            .expect("feedback.js must define renderModelPicker");
2906        let after_picker = &feedback[picker_start..];
2907        let picker_end = after_picker[1..]
2908            .find("\nfunction ")
2909            .or_else(|| after_picker[1..].find("\nasync function "))
2910            .map(|p| p + 1)
2911            .unwrap_or(after_picker.len());
2912        let picker_body = &after_picker[..picker_end];
2913        assert!(
2914            !picker_body.contains("incrementFeedbackCount()"),
2915            "renderModelPicker must NOT call incrementFeedbackCount \
2916             (model discovery doesn't count); feedback={feedback}"
2917        );
2918    }
2919
2920    // --- AC-2 (issue #97): password protection — pure Rust AES-256-GCM -------
2921
2922    /// The password used in encrypt_site_files tests — distinctive enough that a
2923    /// false-positive substring match in any emitted file is unlikely.
2924    const TEST_PASSWORD: &str = "test-password-12345";
2925
2926    #[test]
2927    fn encrypt_site_files_password_protects_all_content_files() {
2928        // AC-2 (issue #97): encrypt_site_files produces a SiteFiles where every
2929        // content file is encrypted and every infrastructure file is unchanged.
2930        // 17 clauses pin the invariant, tested for BOTH targets (clause 16).
2931        //
2932        // The probe: cargo test -p blendtutor-core --lib \
2933        //   site::tests::encrypt_site_files_password_protects_all_content_files
2934        //
2935        // Note: PBKDF2 with 600k iterations is intentionally slow. This test
2936        // minimizes encrypt calls: one full encrypt_site_files per target, plus
2937        // a few crypto::encrypt calls for the salt/nonce uniqueness clauses.
2938
2939        for (target, course) in [
2940            (BuildTarget::Webr, r_course()),
2941            (BuildTarget::Pyodide, python_course()),
2942        ] {
2943            let planned = plan(&course, target).expect("plans");
2944            let mut rng = rand_core::OsRng;
2945            let encrypted = encrypt_site_files(&planned, TEST_PASSWORD, None, &mut rng);
2946            let index = &file(&encrypted, "index.html").contents;
2947
2948            // --- Clause 1: index.html is decrypt shell with data-encrypted-payload/
2949            //     salt/iv base64 attrs ---
2950            assert!(
2951                index.contains("data-encrypted-payload="),
2952                "{target}: index.html must have data-encrypted-payload attr"
2953            );
2954            assert!(
2955                index.contains("data-salt="),
2956                "{target}: index.html must have data-salt attr"
2957            );
2958            assert!(
2959                index.contains("data-iv="),
2960                "{target}: index.html must have data-iv attr"
2961            );
2962            let payload_val = extract_data_attr(index, "data-encrypted-payload")
2963                .unwrap_or_else(|| panic!("{target}: data-encrypted-payload has a value"));
2964            let salt_val = extract_data_attr(index, "data-salt")
2965                .unwrap_or_else(|| panic!("{target}: data-salt has a value"));
2966            let iv_val = extract_data_attr(index, "data-iv")
2967                .unwrap_or_else(|| panic!("{target}: data-iv has a value"));
2968            assert!(!payload_val.is_empty(), "{target}: payload is non-empty");
2969            assert!(!salt_val.is_empty(), "{target}: salt is non-empty");
2970            assert!(!iv_val.is_empty(), "{target}: iv is non-empty");
2971
2972            // --- Clause 2: password input + decrypt button present ---
2973            assert!(
2974                index.contains(r#"type="password""#) && index.contains("decrypt-password"),
2975                "{target}: index.html must have a password input"
2976            );
2977            assert!(
2978                index.contains(r#"id="decrypt-button""#),
2979                "{target}: index.html must have a decrypt button"
2980            );
2981
2982            // --- Clause 3: inline crypto.subtle.deriveKey (PBKDF2) +
2983            //     crypto.subtle.decrypt (AES-GCM) JS ---
2984            assert!(
2985                index.contains("crypto.subtle.deriveKey"),
2986                "{target}: inline JS must call crypto.subtle.deriveKey (PBKDF2)"
2987            );
2988            assert!(
2989                index.contains("crypto.subtle.decrypt"),
2990                "{target}: inline JS must call crypto.subtle.decrypt (AES-GCM)"
2991            );
2992
2993            // --- Clause 4: no plaintext lesson content in index.html ---
2994            for plaintext_marker in [
2995                r#"<header class="site-header">"#,
2996                r#"<main class="workspace">"#,
2997                r#"<footer class="site-footer">"#,
2998                "lesson-select",
2999                "Run checks",
3000                "Submit for feedback",
3001                "Booting webR",
3002                "Booting Pyodide",
3003            ] {
3004                assert!(
3005                    !index.contains(plaintext_marker),
3006                    "{target}: encrypted index.html must NOT contain plaintext \
3007                     page-shell content `{plaintext_marker}`"
3008                );
3009            }
3010
3011            // --- Clause 5: every lessons/*.json is encrypted ciphertext
3012            //     (serde_json::from_str fails) ---
3013            let lesson_files: Vec<_> = encrypted
3014                .files()
3015                .iter()
3016                .filter(|f| {
3017                    f.path.starts_with("lessons/") && f.path.to_string_lossy().ends_with(".json")
3018                })
3019                .collect();
3020            assert!(
3021                !lesson_files.is_empty(),
3022                "{target}: encrypted site must still have lessons/*.json files"
3023            );
3024            for lf in &lesson_files {
3025                assert!(
3026                    serde_json::from_str::<Value>(&lf.contents).is_err(),
3027                    "{target}: lessons/{} must be encrypted ciphertext (serde_json::from_str \
3028                     fails), got: {}",
3029                    lf.path.display(),
3030                    &lf.contents
3031                );
3032            }
3033
3034            // --- Clause 6: lessons.json is encrypted ciphertext ---
3035            let lessons_json = &file(&encrypted, "lessons.json").contents;
3036            assert!(
3037                serde_json::from_str::<Value>(lessons_json).is_err(),
3038                "{target}: lessons.json must be encrypted ciphertext (serde_json::from_str \
3039                 fails), got: {lessons_json}"
3040            );
3041
3042            // --- Clause 7: password string not in any emitted file ---
3043            for f in encrypted.files() {
3044                assert!(
3045                    !f.contents.contains(TEST_PASSWORD),
3046                    "{target}: password string must not appear in any emitted file; found in {}",
3047                    f.path.display()
3048                );
3049            }
3050
3051            // --- Clause 10: IV decodes to 12 bytes, not all zeros ---
3052            let iv_bytes = base64::engine::general_purpose::STANDARD
3053                .decode(&iv_val)
3054                .unwrap_or_else(|e| panic!("{target}: data-iv is valid base64: {e}"));
3055            assert_eq!(
3056                iv_bytes.len(),
3057                12,
3058                "{target}: IV must decode to 12 bytes, got {}",
3059                iv_bytes.len()
3060            );
3061            assert!(
3062                iv_bytes.iter().any(|&b| b != 0),
3063                "{target}: IV must not be all zeros"
3064            );
3065
3066            // --- Clause 11: inline JS contains literal 600000 (PBKDF2 iterations) ---
3067            assert!(
3068                index.contains("600000"),
3069                "{target}: inline JS must contain literal 600000 (PBKDF2 iterations)"
3070            );
3071
3072            // --- Clause 12: coi-serviceworker.js script before inline decrypt
3073            //     script (BOTH targets) ---
3074            let coi_pos = index
3075                .find(r#"src="coi-serviceworker.js""#)
3076                .unwrap_or_else(|| panic!("{target}: coi-serviceworker.js must be referenced"));
3077            let inline_script_pos = index
3078                .rfind("<script>")
3079                .unwrap_or_else(|| panic!("{target}: must have an inline <script> block"));
3080            assert!(
3081                coi_pos < inline_script_pos,
3082                "{target}: coi-serviceworker.js must appear before the inline decrypt script"
3083            );
3084
3085            // --- Clause 13: decrypt JS has catch block with error message ---
3086            assert!(
3087                index.contains("catch"),
3088                "{target}: decrypt JS must have a catch block"
3089            );
3090            assert!(
3091                index.to_lowercase().contains("decryption failed")
3092                    || index.to_lowercase().contains("wrong password"),
3093                "{target}: decrypt JS catch block must have an error message"
3094            );
3095
3096            // --- Clause 14: shared assets still emitted unchanged ---
3097            for infra in [
3098                "lesson-runner.js",
3099                "lesson-runner-core.js",
3100                "coi-serviceworker.js",
3101                "config.js",
3102                "feedback.js",
3103                "styles.css",
3104                "codemirror.js",
3105            ] {
3106                assert_eq!(
3107                    file(&planned, infra).contents,
3108                    file(&encrypted, infra).contents,
3109                    "{target}: {infra} must be byte-identical before and after encryption"
3110                );
3111            }
3112
3113            // --- Clause 15: eval-results.html also encrypted ---
3114            let eval_html = &file(&encrypted, "eval-results.html").contents;
3115            assert!(
3116                eval_html.contains("data-encrypted-payload="),
3117                "{target}: eval-results.html must be a decrypt shell with data-encrypted-payload"
3118            );
3119            assert!(
3120                eval_html.contains("data-salt=") && eval_html.contains("data-iv="),
3121                "{target}: eval-results.html must have data-salt and data-iv attrs"
3122            );
3123            assert!(
3124                !eval_html.contains("Eval results") || eval_html.contains("password required"),
3125                "{target}: eval-results.html must not contain plaintext eval content"
3126            );
3127
3128            // --- Clause 16: both Webr and Pyodide targets work ---
3129            // (This loop runs for both targets — reaching here for both proves it.)
3130
3131            // --- Clause 18: fetch monkeypatch has try/catch fallback for
3132            //     non-encrypted responses (404 HTML, third-party resources with
3133            //     "lessons/" in URL). Without this, atob() throws
3134            //     InvalidCharacterError on non-base64 content and the fetch
3135            //     promise rejects with no fallback. response.clone() is
3136            //     required because response.text() consumes the body — without
3137            //     clone, the catch block would return an empty response. ---
3138            {
3139                let fetch_start = index
3140                    .find("window.fetch =")
3141                    .unwrap_or_else(|| panic!("{target}: must have fetch monkeypatch"));
3142                let rest = &index[fetch_start..];
3143                let fetch_end = rest
3144                    .find("};")
3145                    .unwrap_or_else(|| panic!("{target}: fetch monkeypatch must be closed"));
3146                let fetch_code = &rest[..fetch_end];
3147                assert!(
3148                    fetch_code.contains("try") && fetch_code.contains("catch"),
3149                    "{target}: fetch monkeypatch must have try/catch for non-encrypted responses"
3150                );
3151                assert!(
3152                    fetch_code.contains("response.clone()"),
3153                    "{target}: fetch monkeypatch must clone response before reading (body consumption guard)"
3154                );
3155                assert!(
3156                    fetch_code.contains("return response"),
3157                    "{target}: fetch monkeypatch catch block must fall back to returning original response"
3158                );
3159            }
3160
3161            // --- Clause 19: fetch monkeypatch derives a PER-FILE key using
3162            //     each file's own salt, not the index.html-derived key.
3163            //     encrypt_site_files generates a fresh salt per file, so
3164            //     reusing derivedKey (derived from index.html's salt) fails
3165            //     the GCM auth tag on every lesson JSON. The monkeypatch must
3166            //     call crypto.subtle.deriveKey with the file's salt and use
3167            //     that per-file key for decryption. ---
3168            {
3169                let fetch_start = index
3170                    .find("window.fetch =")
3171                    .unwrap_or_else(|| panic!("{target}: must have fetch monkeypatch"));
3172                let rest = &index[fetch_start..];
3173                let fetch_end = rest
3174                    .find("};")
3175                    .unwrap_or_else(|| panic!("{target}: fetch monkeypatch must be closed"));
3176                let fetch_code = &rest[..fetch_end];
3177                assert!(
3178                    fetch_code.contains("deriveKey"),
3179                    "{target}: fetch monkeypatch must derive a per-file key (deriveKey call)"
3180                );
3181                assert!(
3182                    fetch_code.contains("salt: salt"),
3183                    "{target}: fetch monkeypatch must derive key from the file's own salt, not index.html's salt"
3184                );
3185                assert!(
3186                    !fetch_code.contains("derivedKey"),
3187                    "{target}: fetch monkeypatch must NOT reuse derivedKey (index.html key) for lesson decryption"
3188                );
3189            }
3190
3191            // --- Clause 20: password stored at top level so the fetch
3192            //     monkeypatch can derive per-file keys. The monkeypatch runs
3193            //     outside the click handler, so it needs access to the password
3194            //     via a top-level variable. ---
3195            assert!(
3196                index.contains("userPassword"),
3197                "{target}: decrypt shell must store password in a top-level userPassword variable for per-file key derivation"
3198            );
3199            assert!(
3200                index.contains("userPassword = password"),
3201                "{target}: decrypt shell must capture the password into userPassword in the click handler"
3202            );
3203        }
3204
3205        // --- Clause 8: two calls with same password+content yield different
3206        //     salts (uses crypto::encrypt directly to avoid full-site re-encrypt) ---
3207        let mut rng_a = rand_core::OsRng;
3208        let mut rng_b = rand_core::OsRng;
3209        let p1 = crate::crypto::encrypt("same content", TEST_PASSWORD, &mut rng_a);
3210        let p2 = crate::crypto::encrypt("same content", TEST_PASSWORD, &mut rng_b);
3211        assert_ne!(p1.salt, p2.salt, "two calls must yield different salts");
3212
3213        // --- Clause 9: two calls yield different nonces ---
3214        assert_ne!(
3215            p1.nonce, p2.nonce,
3216            "two calls must yield different nonces (GCM catastrophic-failure guard)"
3217        );
3218
3219        // --- Clause 17: roundtrip — crypto::decrypt(crypto::encrypt(...)) ---
3220        let mut rng = rand_core::OsRng;
3221        let plaintext = "hello, encrypted world!";
3222        let payload = crate::crypto::encrypt(plaintext, "secret", &mut rng);
3223        let decrypted = crate::crypto::decrypt(&payload, "secret")
3224            .expect("roundtrip with correct password must succeed");
3225        assert_eq!(
3226            decrypted, plaintext,
3227            "crypto roundtrip must recover the original plaintext"
3228        );
3229
3230        // Negative: wrong password = Err
3231        assert!(
3232            crate::crypto::decrypt(&payload, "wrong").is_err(),
3233            "wrong password must fail decryption"
3234        );
3235
3236        // Negative: base64-encoding-as-encryption caught — lesson JSONs are not
3237        // valid JSON (they are base64 ciphertext, not JSON). Reuses the Webr
3238        // encrypted site from the loop above.
3239        let webr_planned = plan(&r_course(), BuildTarget::Webr).expect("plans");
3240        let mut rng = rand_core::OsRng;
3241        let enc = encrypt_site_files(&webr_planned, TEST_PASSWORD, None, &mut rng);
3242        let lesson0 = &file(&enc, "lessons/0.json").contents;
3243        assert!(
3244            serde_json::from_str::<Value>(lesson0).is_err(),
3245            "lesson JSON must not be valid JSON (base64-encoding-as-encryption guard)"
3246        );
3247
3248        // Negative: JSON-bypass caught — ALL lesson JSONs are encrypted, not
3249        // just index.html.
3250        let lesson_count = enc
3251            .files()
3252            .iter()
3253            .filter(|f| {
3254                f.path.starts_with("lessons/") && f.path.to_string_lossy().ends_with(".json")
3255            })
3256            .count();
3257        for i in 0..lesson_count {
3258            let path = format!("lessons/{i}.json");
3259            let content = &file(&enc, &path).contents;
3260            assert!(
3261                serde_json::from_str::<Value>(content).is_err(),
3262                "JSON-bypass guard: {path} must be encrypted (not plaintext JSON)"
3263            );
3264        }
3265
3266        // Negative: hardcoded-key JS caught — the decrypt shell must read the
3267        // password from the user input, not contain a hardcoded key.
3268        let index = &file(&enc, "index.html").contents;
3269        assert!(
3270            index.contains("decrypt-password"),
3271            "decrypt shell must read password from user input (no hardcoded key)"
3272        );
3273        assert!(
3274            !index.contains("hardcoded") && !index.contains("HARDCODED"),
3275            "decrypt shell must not reference a hardcoded key"
3276        );
3277    }
3278
3279    /// Extract the value of a `data-*` attribute from an HTML string.
3280    /// Returns the attribute value (between the quotes), or None if not found.
3281    fn extract_data_attr(html: &str, attr: &str) -> Option<String> {
3282        let needle = format!(r#"{attr}=""#);
3283        let pos = html.find(&needle)?;
3284        let start = pos + needle.len();
3285        let rest = &html[start..];
3286        let end = rest.find('"')?;
3287        Some(rest[..end].to_string())
3288    }
3289
3290    /// Extract an EncryptedPayload from a decrypt shell's data-* attributes.
3291    /// Reconstructs the salt, nonce, and ciphertext from the separate base64
3292    /// attributes the decrypt shell emits.
3293    fn extract_encrypted_payload(html: &str) -> crypto::EncryptedPayload {
3294        let ciphertext = base64::engine::general_purpose::STANDARD
3295            .decode(
3296                extract_data_attr(html, "data-encrypted-payload")
3297                    .expect("decrypt shell must have data-encrypted-payload"),
3298            )
3299            .expect("ciphertext is valid base64");
3300        let salt_bytes = base64::engine::general_purpose::STANDARD
3301            .decode(
3302                extract_data_attr(html, "data-salt").expect("decrypt shell must have data-salt"),
3303            )
3304            .expect("salt is valid base64");
3305        let nonce_bytes = base64::engine::general_purpose::STANDARD
3306            .decode(extract_data_attr(html, "data-iv").expect("decrypt shell must have data-iv"))
3307            .expect("iv is valid base64");
3308        let mut salt = [0u8; 16];
3309        salt.copy_from_slice(&salt_bytes);
3310        let mut nonce = [0u8; 12];
3311        nonce.copy_from_slice(&nonce_bytes);
3312        crypto::EncryptedPayload {
3313            ciphertext,
3314            salt,
3315            nonce,
3316        }
3317    }
3318
3319    #[test]
3320    fn is_content_file_classifies_index_and_eval_results_as_content() {
3321        // index.html and eval-results.html are content files that get encrypted.
3322        // Kills the mutant that replaces || with && in the first two conditions:
3323        // `path == "index.html" && path == "eval-results.html"` is always false
3324        // (a path can't equal both), so both would be misclassified as non-content.
3325        assert!(
3326            is_content_file("index.html"),
3327            "index.html must be classified as a content file"
3328        );
3329        assert!(
3330            is_content_file("eval-results.html"),
3331            "eval-results.html must be classified as a content file"
3332        );
3333    }
3334
3335    #[test]
3336    fn is_content_file_rejects_non_content_files_matching_one_condition() {
3337        // A file that starts with "lessons/" but doesn't end in ".json" is NOT
3338        // a content file. Kills the mutant that replaces && with || in the
3339        // last condition: `starts_with("lessons/") || ends_with(".json")`
3340        // would classify "lessons/readme.txt" as content (matching starts_with
3341        // alone) and "config.json" as content (matching ends_with alone).
3342        assert!(
3343            !is_content_file("lessons/readme.txt"),
3344            "lessons/readme.txt must NOT be a content file (not a .json)"
3345        );
3346        assert!(
3347            !is_content_file("config.json"),
3348            "config.json must NOT be a content file (not under lessons/)"
3349        );
3350    }
3351
3352    #[test]
3353    fn encrypt_site_files_embeds_key_only_in_index_html() {
3354        // When an embed_key is provided, index.html's plaintext is JSON-wrapped
3355        // ({"html":"...","embeddedKey":{...}}) so the decrypt shell can extract
3356        // the key. eval-results.html does NOT receive the JSON wrapping — it
3357        // stays as plain encrypted HTML.
3358        //
3359        // Kills the mutant that replaces == with != in
3360        // `if path_str == "index.html"`: the mutant swaps the behavior,
3361        // JSON-wrapping eval-results.html instead of index.html.
3362        let planned = plan(&r_course(), BuildTarget::Webr).expect("plans");
3363        let mut rng = rand_core::OsRng;
3364        let embed_key = EmbeddedKey {
3365            provider: "fireworks".to_string(),
3366            key: "fw_testkey123".to_string(),
3367        };
3368        let encrypted = encrypt_site_files(&planned, TEST_PASSWORD, Some(&embed_key), &mut rng);
3369
3370        // index.html: decrypt and verify the plaintext contains embeddedKey JSON.
3371        let index_html = &file(&encrypted, "index.html").contents;
3372        let index_payload = extract_encrypted_payload(index_html);
3373        let index_plaintext = crypto::decrypt(&index_payload, TEST_PASSWORD)
3374            .expect("index.html decrypts with the correct password");
3375        assert!(
3376            index_plaintext.contains("embeddedKey"),
3377            "index.html plaintext must contain embeddedKey JSON wrapping, got: {index_plaintext}"
3378        );
3379        assert!(
3380            index_plaintext.contains("fw_testkey123"),
3381            "index.html plaintext must contain the embedded API key, got: {index_plaintext}"
3382        );
3383
3384        // eval-results.html: decrypt and verify the plaintext does NOT contain
3385        // embeddedKey JSON — it stays as plain HTML.
3386        let eval_html = &file(&encrypted, "eval-results.html").contents;
3387        let eval_payload = extract_encrypted_payload(eval_html);
3388        let eval_plaintext = crypto::decrypt(&eval_payload, TEST_PASSWORD)
3389            .expect("eval-results.html decrypts with the correct password");
3390        assert!(
3391            !eval_plaintext.contains("embeddedKey"),
3392            "eval-results.html plaintext must NOT contain embeddedKey JSON wrapping, got: {eval_plaintext}"
3393        );
3394    }
3395}