app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924
//! `auto`: run every program-only step in order, and stop with one clear line when the AI's judgement is needed (naming the
//! file or command it wants), when the run is complete, or when a command fails. A script alternates `auto` with the AI's
//! inputs until it says COMPLETE.
//!
//! `plan` decides the one next action from the files as they stand, so it gives the same answer whoever asks: `auto` runs it,
//! and `next` prints it. Nothing here measures or scores: each action is one of the flow's own commands, run as a child of
//! this program, so the ledger shows the same lines a hand-run flow would.
//!
//! The order fixes the known rework loop. `connect attribute` writes nets into copper.json, which makes `check mimic` and the
//! snapshot stale. Attribution therefore runs in the copper step, before anything is built on the copper, and whenever it has
//! to run again (the AI added pin names to nets.json) the plan goes back by itself: build, `check mimic`, snapshot, then on.
use crate::{attrib, checks, dspins, pinnets, flow, items, layerstate, photomat};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
/// The one next thing to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// A program-only command (the arguments after the binary's name), and why it is next.
Run(Vec<String>, String),
/// The AI's input: its name, then lines that say exactly what file or command is wanted.
NeedAi(String, Vec<String>),
Complete,
}
const RECORD: &str = "auto.json";
const LOG: &str = "auto-log.txt";
fn rd(f: &Path, name: &str) -> Value { items::read_json(&f.join(name)).unwrap_or(Value::Null) }
fn has(f: &Path, name: &str) -> bool { f.join(name).exists() }
fn run(cmd: &[&str], why: &str) -> Action { Action::Run(cmd.iter().map(|s| s.to_string()).collect(), why.to_string()) }
fn need(name: &str, lines: Vec<String>) -> Action { Action::NeedAi(name.to_string(), lines) }
fn fresh(f: &Path, check: &str) -> bool { flow::exists(f, &format!("checks/{check}.ok")) }
fn record(f: &Path) -> Value { let v = rd(f, RECORD); if v.is_object() { v } else { json!({}) } }
fn newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x >= y, (Ok(_), Err(_)) => true, _ => false } }
/// Remember something about what `auto` did, so the plan does not repeat it: a file's hash right after a command wrote it.
fn remember(f: &Path, key: &str, v: Value) {
let mut r = record(f); r[key] = v;
let _ = std::fs::write(f.join(RECORD), serde_json::to_string_pretty(&r).unwrap_or_default() + "\n");
}
fn strictly_newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x > y, _ => false } }
/// Where every part sits and which way it is turned: what the joins of pads to copper were read against. A value, a marking or a
/// pin name does not move a pad, so it is not in here.
pub fn placement_key(f: &Path) -> String {
let doc = rd(f, "parts.json");
let mut lines: Vec<String> = doc["parts"].as_array().into_iter().flatten().filter(|p| p["onBoard"] != json!(false))
.map(|p| format!("{}|{}|{}|{}|{}", p["ref"].as_str().unwrap_or(""), p["side"].as_str().unwrap_or(""), p["pos_mm"], p["rot"], p["footprint"].as_str().unwrap_or(""))).collect();
lines.sort();
crate::confidence::sha256_hex(lines.join("\n").as_bytes())
}
/// The pads are placed by their own metal once, after the AI has completed parts.json. This holds whichever step the run is at:
/// a `check parts` passed by hand must not let the run go on with parts that were never snapped. Only for a parts.json that
/// `auto` saw start from `parts init`, so an older run is not moved.
fn unsnapped(f: &Path) -> Option<Action> {
if !has(f, "parts.json") { return None; }
let r = record(f);
let sha = crate::sha256(&f.join("parts.json"));
let untouched = r["partsUntouched"].as_str()?;
if untouched == sha || r["partsSnapped"].as_str() == Some(sha.as_str()) { return None; }
let doc = rd(f, "parts.json");
let list = doc["parts"].as_array()?;
if list.is_empty() || list.iter().any(|p| p["snap"].is_object()) { return None; }
Some(run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"))
}
/// The footprint files the parts name that are newer than the board: a footprint written or corrected after the build.
fn newer_footprints(f: &Path, pcb: &Path) -> Vec<String> {
let cache = crate::kicad_out::cache_dir(f);
let mut ids: Vec<String> = rd(f, "parts.json")["parts"].as_array().into_iter().flatten().filter_map(|p| p["footprint"].as_str().map(str::to_string)).collect();
ids.sort(); ids.dedup();
ids.into_iter().filter(|id| id.split_once(':').is_some_and(|(lib, name)| { let p = cache.join(lib).join(format!("{name}.kicad_mod")); p.is_file() && strictly_newer(&p, pcb) })).collect()
}
/// What the last build could not place (`build-left-out.json`), as the lines the AI needs: the part, the footprint, the file wanted.
fn left_out(f: &Path, pcb: &Path) -> Option<Action> {
let p = f.join(crate::kicad_out::LEFT_OUT);
if !p.is_file() || strictly_newer(pcb, &p) { return None; }
let rows = rd(f, crate::kicad_out::LEFT_OUT)["leftOut"].as_array().cloned().unwrap_or_default();
if rows.is_empty() { return None; }
let mut lines: Vec<String> = rows.iter().map(|r| format!("{}: left out of the board: {}", r["ref"].as_str().unwrap_or("?"), r["why"].as_str().unwrap_or(""))).collect();
lines.push("Give each part a footprint that loads: a KiCad library name in parts.json `footprint` (Library:Name), or your own file at reverse-flow/lib/fp/<Library>/<Name>.kicad_mod in the layout KiCad 8 or later saves (one token per line, as the library files beside it). Then run `auto` again: a new or changed footprint file makes the build run again.".into());
Some(need("a footprint the build can load", lines))
}
/// Attribution, when it has to run (again): the photos' reading first, then the nets. `None` when it is up to date.
fn attribution(f: &Path) -> Option<Action> {
if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return None; }
// a part moved or turned since the copper was read: its pads are joined again where they are now
{ let (r, pk) = (record(f), placement_key(f));
if r["placementAtTrace"].as_str().is_some_and(|k| k != pk) { return Some(run(&["copper", "trace"], "a part moved, turned or changed footprint since the copper was traced: the pads are joined to the copper again where they are now")); }
if r["placementAtMeasure"].as_str().is_some_and(|k| k != pk) { return Some(run(&["connect", "measure"], "a part moved, turned or changed footprint since the photos were read for attribution: each pad's edge is read again")); } }
let key = attrib::zones_key(&rd(f, "copper.json"));
let ms = rd(f, "detect/attrib/measure.json");
if ms.is_null() || ms["copperSha"].as_str().is_some_and(|s| s != key) || ms["stitch"].is_null() {
return Some(run(&["connect", "measure"], "the photos are read once for attribution, on the copper as traced"));
}
// nets from the parts first: the pinout nets are built, or built again, before the copper is held against them
if pinnets::stale(f) { return Some(run(&["connect", "pinout"], "nets from the parts first: every pin with a known function gets its net by rule, before the copper confirms or contradicts")); }
// nets.json missing is no obstacle: `connect attribute` starts it empty, and it is the step that does
if !has(f, "nets.json") { return Some(run(&["connect", "attribute"], "there is no nets.json yet: attribution starts it and names the pins that share copper")); }
let r = record(f);
let edited = has(f, "nets.json") && r["netsAfterAttribute"].as_str().is_some_and(|h| h != crate::sha256(&f.join("nets.json")));
if !has(f, "detect/attribution.json") || !newer(&f.join("detect/attribution.json"), &f.join("detect/attrib/measure.json")) || edited {
return Some(run(&["connect", "attribute"], if edited { "nets.json changed since attribution: the copper takes its nets again" } else { "every pour, track and via gets a net with graded evidence" }));
}
// pin names from a datasheet that came, went or changed since: the copper at those pins takes its names again
// `check copper` recorded a face as not read (or a new trace cleared that): the nets are made again, without that face's joins or with them
if r["notReadAtAttribute"].as_str().unwrap_or("") != crate::notread::key(f) { return Some(run(&["connect", "attribute"], "a face's copper was recorded as NOT READ, or read again: the nets are made again, and none is claimed through a face that was not read")); }
if r["pinNamesAtAttribute"].as_str().unwrap_or("") != dspins::Names::load(f).key() { return Some(run(&["connect", "attribute"], "the datasheet pin names changed since attribution: the copper at those pins is named again")); }
None
}
/// The one stop for datasheets, after the parts are identified and before anything is named or drawn from them: every
/// identified chip has its pins file or a reasoned "none available", and parts.json carries the pin names. `None` when it does.
fn datasheets(f: &Path) -> Option<Action> {
let st = dspins::status(f);
if !st.missing.is_empty() || !st.bad.is_empty() || !st.unread.is_empty() { return Some(need("datasheet pins", dspins::ask_lines(&st))); }
if !dspins::applied(f, &st) { return Some(run(&["datasheet", "apply"], "the datasheet pin names and types go into parts.json, for the schematic and the net names")); }
// part, then orientation, then pinout: a chip whose pinout is known and whose orientation is not gets no nets until it is settled
let asks = pinnets::unsettled(f);
if !asks.is_empty() { let mut lines = vec!["A pinout on a chip turned the wrong way is worse than none. For each chip below, settle which way round it sits, then run `auto` again: it builds the nets from the pinouts by itself.".to_string()]; lines.extend(asks); return Some(need("orientation of chips with a pinout", lines)); }
None
}
/// The audits a review pass is waiting for, as exact instructions; or a program step that has to come first.
fn audits(scores: &Value, drawn: &[String]) -> Result<Vec<String>, Action> {
let mut lines = vec![];
for (cat, c) in scores["categories"].as_object().into_iter().flatten() {
// a sample drawn since these scores were computed is judged by the next pass, not drawn again
if c["heldByAudit"] != json!(true) || drawn.contains(cat) { continue; }
let a = &c["audit"];
let status = a["status"].as_str().unwrap_or("");
match status {
"void" | "stale" => return Err(run(&["audit", "draw", "--category", cat], &format!("the {cat} sample is {status}: a new one is drawn"))),
s if s.starts_with("scored") => {
let n = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["needForTarget"].as_u64()).max().unwrap_or(0);
let have = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["audited"].as_u64()).min().unwrap_or(0);
if n > have { return Err(run(&["audit", "draw", "--category", cat, "--n", &n.to_string()], &format!("the {cat} sample is too small to reach the target: it is extended to {n} per stratum"))); }
return Err(run(&["audit", "draw", "--category", cat], &format!("items of the {cat} sample changed since they were judged: a new sample is drawn")));
}
"tampered" => lines.push(format!("{cat}: the sample files were changed by hand ({}): restore reverse-flow/audits/{cat}/ from the ledger, then `audit draw --category {cat}`", a["detail"].as_str().unwrap_or(""))),
_ => lines.push(format!("{cat}: {} of {} verdicts. An auditor who did not produce this run (another AI thread or a person) judges every crop in reverse-flow/audits/{cat}/sheet.md and sheet-*.jpg, then: audit verdict --category {cat} --sample {} --file <answers.json> --who <auditor> (answers.json: [{{\"item\": \"a001\", \"verdict\": \"ok|wrong|cannot_tell\", \"note\": \"\"}}, ...])", a["verdicts"], a["items"], a["sample"].as_str().unwrap_or(""))),
}
}
Ok(lines)
}
/// After the last step: the run video. Built once per report; a build that failed on these files is not tried again,
/// because a missing video never holds a run up.
fn video(f: &Path) -> Action {
// the bench instruction set is a deliverable of every run (the optional passback): written once, before the video.
// A failure is remembered and never holds the run up.
if !f.join("bench/plan.md").exists() && record(f)["benchPlanFailed"].is_null() { return run(&["bench", "plan"], "the instruction set for a person with the real board (the optional passback)"); }
if crate::video::fresh(f) || record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return Action::Complete; }
run(&["video", "build"], "the two minute video of the run, made from its files")
}
/// What `next` and `steps` say about the video once every step is done: nothing while it is still to build or built, the
/// reason when the last build failed.
pub fn video_note(f: &Path) -> Vec<String> {
let r = record(f);
if crate::video::fresh(f) { return vec![format!("the run video: {}", crate::video::record(f)["file"].as_str().unwrap_or("reverse-flow/video/"))]; }
if r["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return vec![format!("the run video was not built: {}. The run is complete without it; `video build` tries again", r["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt"))]; }
vec![]
}
/// What comes next, from the files alone.
pub fn plan(f: &Path, fl: &flow::Flow) -> Action {
let Some(step) = flow::current(fl, f) else { return video(f) };
// nets from the parts first, wherever the run stands: a run past attribution (waiting for audits, at the tests or the report) whose pin
// tables, orientations or copper are newer than its pinout nets, or that never had any, gets them built before anything else is done
if !matches!(step.name.as_str(), "intake" | "scale" | "parts" | "blocks" | "copper" | "mimic" | "snapshot" | "connect") && pinnets::stale(f) {
return run(&["connect", "pinout"], "the pinout nets are older than the pin tables, the orientations or the copper they are built from (or were never built): they are built first, and the flow goes back to hold the copper against them");
}
let state = rd(f, "state.json");
let kdir = f.parent().map(|p| p.join("kicad")).unwrap_or_else(|| PathBuf::from("kicad"));
let pcb = state["kicad"]["pcb"].as_str().map(PathBuf::from);
// the faces were stitched again (`stitch --top <photo>`) after the photos were measured: what was measured is of the old faces
// Told by what the files hold, not by their times (a copied run folder has new times): the stitch record `prep` last ran on, or the one `check prep` passed on
if !crate::intake::pending(&state) && has(f, "prep.json") && has(f, "stitch.json") {
let moved = record(f)["stitchAtPrep"].as_str().is_some_and(|h| h != crate::sha256(&f.join("stitch.json"))) || flow::stale(f, "checks/prep.ok").iter().any(|x| x == "stitch.json");
if moved { return run(&["prep"], "the faces were stitched again after the photos were measured: every photo is measured again"); }
}
if !matches!(step.name.as_str(), "intake" | "scale" | "parts") { if let Some(a) = unsnapped(f) { return a; } }
match step.name.as_str() {
"intake" => {
// an unsorted set of photos (`init --photos`) is sorted and stitched before anything is measured
if crate::intake::pending(&state) { return run(&["stitch"], "the photos are sorted into faces, tiles and overviews and stitched into one image per face"); }
if !has(f, "prep.json") { return run(&["prep"], "every photo is measured"); }
run(&["check", "prep"], "the photos' measurements are checked")
}
"scale" => need("scale", vec![
"Pick one part body whose size a datasheet gives (body ids are drawn on reverse-flow/evidence/prep-<top photo>.jpg) and run: scale --body <id>=<length>x<width> --source \"<datasheet, package drawing>\"".into(),
"A board with no rows of holes and four mounting holes at a spacing a document gives: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y]".into(),
]),
"parts" => {
if !has(f, "detect/parts.json") {
let bottom = image::open(f.join("board/bottom.jpg")).ok().map(|i| photomat::carries_parts(&i.to_rgb8())).unwrap_or(false);
return if bottom { run(&["detect", "parts", "--bottom"], "part candidates on both faces: the bottom photo shows parts") } else { run(&["detect", "parts"], "part candidates on the top face: the bottom photo shows no parts") };
}
if !has(f, "parts.json") { return run(&["parts", "init"], "parts.json from the candidates"); }
let r = record(f);
let sha = crate::sha256(&f.join("parts.json"));
if r["partsUntouched"].as_str() == Some(sha.as_str()) {
return need("parts.json", vec![
"Read every crop in reverse-flow/evidence/parts/ (contact sheets: evidence/parts-sheet-*.jpg, map: evidence/parts-map.jpg) and complete reverse-flow/parts.json: for each part its ref, value, marking, mpn, function, the footprint where the size guess is wrong, rot, and pin1.mark for every part that fits one way; tag each field by how it is known (fieldTags).".into(),
"Add every candidate in `unassigned` as a part, or list it in `dismissed` with why. `decode <code>` reads resistor markings. Then run `auto` again: it snaps every footprint to its pads once, also when you ran `check parts` yourself first. A part you add after that is snapped with `parts snap` by you.".into(),
]);
}
let doc = rd(f, "parts.json");
let used: std::collections::HashSet<&str> = doc["parts"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str()).chain(doc["dismissed"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str())).collect();
let loose = rd(f, "detect/parts.json")["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).any(|c| !used.contains(c));
if loose && r["partsReconciled"].as_str() != Some(sha.as_str()) { return run(&["parts", "reconcile"], "candidates that are fragments of placed parts are dismissed"); }
// pads are snapped once, when no part has been yet. A later edit (a value from the bench, a marking read) must not move every part again:
// that would make the copper, the placement audit and everything built on them stale for nothing. Parts added later are snapped by hand
let never = !doc["parts"].as_array().into_iter().flatten().any(|p| p["snap"].is_object());
if never && r["partsSnapped"].as_str() != Some(sha.as_str()) { return run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"); }
if let Some(a) = datasheets(f) { return a; }
run(&["check", "parts"], "parts.json is checked")
}
"blocks" => {
if !has(f, "blocks.json") {
if !has(f, "detect/blocks-proposal.json") { return run(&["blocks", "propose"], "groups of parts that sit together"); }
return need("blocks.json", vec!["Write reverse-flow/blocks.json from reverse-flow/detect/blocks-proposal.json: {\"blocks\": [{\"id\", \"name\", \"function\", \"parts\": [refs], \"tag\", \"evidence\" or \"tests\"}]}. Name each block, say what it does, move parts between groups where silkscreen or the circuit says so; every part is in exactly one block (holes and fiducials in a `mechanical` block). Then run `auto` again.".into()]);
}
run(&["check", "blocks"], "blocks.json is checked")
}
"copper" => {
if !has(f, "detect/domes.json") { return run(&["copper", "prep"], "gap maps and the vias read from the photos"); }
if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return run(&["copper", "trace"], "every island of copper on both faces"); }
if let Some(a) = attribution(f) { return a; }
run(&["check", "copper"], "copper.json is checked")
}
"mimic" => {
if let Some(a) = attribution(f) { return a; }
let r = record(f);
if !has(f, crate::photolayers::FILE) && r["layersTraceFailed"].is_null() { return run(&["layers", "trace"], "silk, pad shapes, holes and outline read from the photos, for the build to draw"); }
let built = pcb.as_ref().is_some_and(|p| ["copper.json", "parts.json", "nets.json", "layers.json", crate::photolayers::FILE].iter().all(|x| !has(f, x) || newer(p, &f.join(x))));
if !built { return run(&["build"], "the KiCad copy of everything visible"); }
// a board from a build that did not yet record what it left out or could not fill: built again once, so the mimic check has the reasons
if !has(f, crate::kicad_out::LEFT_OUT) { return run(&["build"], "the board was built before `build` recorded what it left out and which pours it could not fill: it is built again"); }
if let Some(p) = &pcb {
let fresh_fp = newer_footprints(f, p);
if !fresh_fp.is_empty() { return run(&["build"], &format!("a footprint file is newer than the board ({}): the copy is built again with it", fresh_fp.join(", "))); }
// a part the build left out is said here, with the footprint it wants, not later as "not on the board"
if let Some(a) = left_out(f, p) { return a; }
}
run(&["check", "mimic"], "the copy is checked against what was measured")
}
"snapshot" => {
if let Some(a) = attribution(f) { return a; }
Action::Run(vec!["snapshot".into(), "--name".into(), "mimic".into(), "--dir".into(), kdir.display().to_string()], "the copy as it stands is saved".into())
}
"connect" => {
if let Some(a) = attribution(f) { return a; }
let l = rd(f, "layers.json");
let routed = l["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && l["method"] != json!("stated");
if !routed && !layerstate::current(f) { return run(&["layers", "state"], "the layer count is stated from what is known, without routing"); }
if !fresh(f, "nets") { return run(&["check", "nets"], "nets.json is checked"); }
// the copy carries the nets and the stated stackup: built before either changed, it is built again, so its ratsnest and
// evidence classes are the ones the files give now and nothing an earlier route drew stays in it
if let (Some(p), false) = (&pcb, routed) {
let have = std::fs::read_to_string(p).map(|t| crate::kicad_in::copper_layers(&t)).unwrap_or(0) as u64;
if l["proposed"].as_u64().is_some_and(|n| n != have) { return run(&["build"], &format!("the copy takes the stated {} layers", l["proposed"])); }
if ["nets.json", "layers.json"].iter().any(|x| has(f, x) && !newer(p, &f.join(x))) { return run(&["build"], "the copy is older than the nets or the layer statement: it is built again with the hidden connections left unrouted"); }
}
if !fresh(f, "layers") { return run(&["check", "layers"], "the layer statement is checked"); }
// the schematic follows the nets: its sheets are written again whenever parts, blocks, nets or their evidence are newer,
// and KiCad's ERC, KiCad's netlist against nets.json and the sheet rules must pass before the review. A failure stops here
// a run that passed the parts step before it asked for datasheets is asked here, before its chips are drawn as bare boxes
if let Some(a) = datasheets(f) { return a; }
let sch = state["kicad"]["sch"].as_str().map(PathBuf::from);
let drawn = sch.as_ref().is_some_and(|s| s.is_file() && ["parts.json", "blocks.json", "nets.json", "net-evidence.json"].iter().all(|x| !has(f, x) || newer(s, &f.join(x))));
if !drawn { return run(&["schematic"], "the schematic sheets are drawn from the parts, blocks and nets as they stand"); }
run(&["check", "schematic"], "the schematic is checked: KiCad's ERC, its netlist against nets.json, the sheet rules")
}
"review" => {
let r = record(f);
let fid = rd(f, "fidelity.json");
let fid_stale = fid.is_null() || fid["inputs"].as_object().into_iter().flatten().any(|(k, h)| h.as_str().is_some_and(|w| !flow::matches_hash(&f.join(k), w)));
let at = format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json")));
if fid_stale && pcb.is_some() && r["fidelityTriedAt"].as_str() != Some(at.as_str()) { return run(&["check", "fidelity"], "what the copy draws is scored against the photos, for the review"); }
let review = rd(f, "review.json");
let last = review["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).cloned());
let now: std::collections::BTreeMap<String, String> = checks::current_hashes(f);
let Some(last) = last else { return run(&["review", "pass"], "the first measured pass of the review loop") };
if r["reviewRedo"] == json!(true) {
let note = last["note"].as_str().unwrap_or("").trim().to_string();
if last["verdict"] == json!("STOPPED") && !note.is_empty() {
let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();
return run(&["review", "pass", "--max-passes", &max, "--note", ¬e], "the recorded pass no longer matches the files: the review measures again and keeps its declared stop");
}
return run(&["review", "pass"], "the recorded pass no longer matches the files: the review measures again");
}
// every sample the last pass found void, stale or too small is drawn before the next pass, so one pass sees them all
let drawn: Vec<String> = if r["drawn"]["pass"] == last["n"] { r["drawn"]["cats"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect() } else { vec![] };
if last["verdict"] == json!("CONTINUE") { if let Err(a) = audits(&last["scores"], &drawn) { return a; } }
let then: std::collections::BTreeMap<String, String> = serde_json::from_value(last["hashes"].clone()).unwrap_or_default();
if then != now {
// a declared stop (a STOPPED pass that carries its reason) holds when later steps change files: the review
// measures again under the same limit and with the same reason, and does not start asking for audits again
let note = last["note"].as_str().unwrap_or("").trim().to_string();
if last["verdict"] == json!("STOPPED") && !note.is_empty() {
let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();
return run(&["review", "pass", "--max-passes", &max, "--note", ¬e], "files changed since the declared stop: the review measures again and stops again for the same reason");
}
return run(&["review", "pass"], "files changed since the last pass: the review measures again");
}
match last["verdict"].as_str().unwrap_or("") {
"TARGET MET" | "STALLED" | "STOPPED" => run(&["check", "review"], "the review's verdict is checked against the files"),
_ => match audits(&last["scores"], &drawn) {
Err(a) => a,
Ok(lines) if !lines.is_empty() => need("independent audit verdicts", lines),
Ok(_) => run(&["review", "pass"], "no audit is outstanding: another pass shows whether anything still rises"),
},
}
}
"tests" => {
let t = f.join("tests.json");
let generated = t.is_file() && items::ARTIFACTS.iter().all(|a| !has(f, a) || newer(&t, &f.join(a)));
if !generated { return run(&["tests", "generate"], "a test for every INFERRED or UNKNOWN item"); }
run(&["check", "tests"], "tests.json is checked")
}
"report" => {
// the net pictures are drawn before the report is written, so whoever writes it has them; `report` draws them again when it passes
let drawn = f.join("nets/index.md");
let stale = !drawn.is_file() || ["nets.json", "detect/attribution.json", "copper.json"].iter().any(|x| has(f, x) && !newer(&drawn, &f.join(x)));
if stale && has(f, "detect/attribution.json") && record(f)["netsDrawFailedAt"].as_str() != Some(crate::sha256(&f.join("nets.json")).as_str()) { return run(&["nets", "draw"], "every net graded by its weakest link and drawn under reverse-flow/nets/, for the report"); }
if !has(f, "report.md") {
if !has(f, "report-tables.md") || !newer(&f.join("report-tables.md"), &f.join("checks/tests.ok")) { return run(&["report"], "the generated tables"); }
let (s, verdict) = crate::report::current(f);
return need("report.md", vec![
"Write reverse-flow/report.md around reverse-flow/report-tables.md: what the board is, the blocks, the layer statement and its evidence, both scores, copied versus inferred, the nets by level (reverse-flow/nets/index.md and confidence.jpg), the tests.".into(),
format!("It must carry this line word for word: {}", crate::confidence::overall_line(&s, &verdict)),
"`report --draft` writes a starting report.md from the tables with that line in it. Then run `auto` again.".into(),
].into_iter().chain(crate::notread::faces(f).iter().map(|x| format!("It must also carry this line word for word: {}", crate::notread::required(x))))
.chain(crate::window::size_sentence(f).filter(|_| !crate::window::cut(f).is_empty()).map(|s| format!("The photos cut the board off: where you state a size, say it is the photographed window. The tables say: {s}"))).collect());
}
run(&["report"], "the report is checked against the computed scores")
}
other => need(other, vec![step.todo.clone()]),
}
}
/// `next` and every step hint: the exact next action.
pub fn now_lines(f: &Path, fl: &flow::Flow) -> Vec<String> {
match plan(f, fl) {
Action::Run(cmd, why) => vec![format!("now: `{}` ({why}). `auto` runs this and every program-only step after it", cmd.join(" "))],
Action::NeedAi(name, lines) => { let mut v = vec![format!("now: your input is needed: {name}")]; v.extend(lines); v }
Action::Complete => vec![],
}
}
/// What a finished command changed that the plan should remember.
fn after(f: &Path, cmd: &[String], ok: bool) {
let c: Vec<&str> = cmd.iter().map(String::as_str).collect();
let parts = || json!(crate::sha256(&f.join("parts.json")));
match (c.as_slice(), ok) {
(["parts", "init"], true) => remember(f, "partsUntouched", parts()),
(["parts", "reconcile"], _) => remember(f, "partsReconciled", parts()),
(["parts", "snap"], _) => remember(f, "partsSnapped", parts()),
(["prep"], true) => { if has(f, "stitch.json") { remember(f, "stitchAtPrep", json!(crate::sha256(&f.join("stitch.json")))); } }
(["copper", "trace"], true) => remember(f, "placementAtTrace", json!(placement_key(f))),
(["connect", "measure"], true) => remember(f, "placementAtMeasure", json!(placement_key(f))),
(["connect", "attribute"], true) => { remember(f, "netsAfterAttribute", json!(crate::sha256(&f.join("nets.json")))); remember(f, "pinNamesAtAttribute", json!(dspins::Names::load(f).key())); remember(f, "notReadAtAttribute", json!(crate::notread::key(f))); }
(["layers", "trace"], false) => remember(f, "layersTraceFailed", json!(true)),
(["nets", "draw"], false) => remember(f, "netsDrawFailedAt", json!(crate::sha256(&f.join("nets.json")))),
(["review", "pass"], _) | (["review"], _) => remember(f, "reviewRedo", json!(false)),
(["audit", "draw", "--category", cat, ..], _) => {
let n = rd(f, "review.json")["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).map(|p| p["n"].clone())).unwrap_or(Value::Null);
let r = record(f);
let mut cats: Vec<Value> = if r["drawn"]["pass"] == n { r["drawn"]["cats"].as_array().cloned().unwrap_or_default() } else { vec![] };
cats.push(json!(cat));
remember(f, "drawn", json!({"pass": n, "cats": cats}));
}
(["check", "fidelity"], _) => remember(f, "fidelityTriedAt", json!(format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json"))))),
_ => {}
}
}
/// What the plan reads, as sizes and times: when a command passed and none of this moved, running it again cannot help.
fn fingerprint(f: &Path, pcb: Option<&Path>) -> String {
let mut files: Vec<PathBuf> = pcb.map(|p| vec![p.to_path_buf()]).unwrap_or_default();
let mut walk = |dir: PathBuf, depth: usize| {
let mut stack = vec![(dir, depth)];
while let Some((d, k)) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let (p, name) = (e.path(), e.file_name().to_string_lossy().to_string());
if p.is_dir() { if k > 0 { stack.push((p, k - 1)); } continue; }
if matches!(name.as_str(), "run.jsonl" | "views.json" | "auto-log.txt" | "serve.log") || !(name.ends_with(".json") || name.ends_with(".jsonl") || name.ends_with(".ok") || name.ends_with(".md")) { continue; }
files.push(p);
}
}
};
walk(f.to_path_buf(), 0);
for (d, k) in [("checks", 0), ("snapshots", 1), ("detect", 1), ("audits", 1), ("bench", 0)] { walk(f.join(d), k); }
files.sort();
let lines: Vec<String> = files.iter().filter_map(|p| { let m = std::fs::metadata(p).ok()?; Some(format!("{}|{}|{:?}", p.display(), m.len(), m.modified().ok()?)) }).collect();
crate::confidence::sha256_hex(lines.join("\n").as_bytes())
}
/// A step that ended without its own `ERROR:` line (killed, or dead on an allocation) still gets a reason: how it ended and
/// the last thing it printed. `None` when the step passed or said what was wrong itself.
fn died(status: &std::process::ExitStatus, text: &str) -> Option<String> {
if status.success() || text.lines().any(|l| l.starts_with("ERROR:")) { return None; }
#[cfg(unix)]
let signal = { use std::os::unix::process::ExitStatusExt; status.signal() };
#[cfg(not(unix))]
let signal: Option<i32> = None;
Some(died_text(status.code(), signal, text))
}
fn died_text(code: Option<i32>, signal: Option<i32>, text: &str) -> String {
let last: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev().take(2).collect();
let said = if last.is_empty() { "it printed nothing".to_string() } else { format!("the last it printed: {}", last.into_iter().rev().map(|l| l.chars().take(240).collect::<String>()).collect::<Vec<_>>().join(" | ")) };
let memory = text.contains("memory allocation of") || text.to_lowercase().contains("out of memory") || text.contains("capacity overflow");
let how = match (signal, code) {
(Some(9), _) => "the step was killed (signal 9) before it could answer: the system or the memory guard stopped it, most often for running out of memory".to_string(),
(Some(6), _) | (_, Some(134)) if memory => "the step ran out of memory and stopped (an allocation failed)".to_string(),
(Some(n), _) => format!("the step was stopped by signal {n} before it could answer"),
(_, Some(c)) if memory => format!("the step ran out of memory and stopped (an allocation failed, exit code {c})"),
(_, Some(c)) => format!("the step ended with exit code {c} and no answer of its own (a crash in the program)"),
_ => "the step ended with no answer of its own".to_string(),
};
format!("{how}; {said}")
}
/// How one `auto` ended.
pub struct End { pub state: &'static str, pub line: String, pub hints: Vec<String>, pub ran: Vec<String> }
/// Run program-only steps until the AI is needed, the run is complete, a command fails or `max` commands have run.
pub fn run_all(f: &Path, project_dir: &Path, fl: &flow::Flow, thread: &str, max: usize, say: &mut dyn FnMut(String)) -> End {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("adom-aiflow-reverse"));
let mut ran: Vec<String> = vec![];
let mut last: Option<(Vec<String>, bool, bool)> = None;
let mut review_redos = 0;
let pcb_of = || rd(f, "state.json")["kicad"]["pcb"].as_str().map(PathBuf::from);
let end = |state: &'static str, line: String, mut hints: Vec<String>, ran: &[String]| {
// what the run carries as failed and no input of yours changes: said at every stop, so nobody waits on it or takes it as passed
for s in crate::notread::sentences(f) { hints.push(format!("Carried as failed, not waiting for you: {s}")); }
remember(f, "last", json!({"state": state, "line": line, "hints": hints, "ran": ran, "at": crate::ledger::now()}));
End { state, line, hints, ran: ran.to_vec() }
};
loop {
let action = plan(f, fl);
let (cmd, why) = match action {
Action::Complete => {
// the video is named in the last line either way: where it is, or plainly that it was not built and why
let failed = !crate::video::fresh(f) && record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str());
let line = if failed { format!("AUTO COMPLETE: every step is done; the video was NOT built: {}", record(f)["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt")) }
else { format!("AUTO COMPLETE: every step is done; video: {}", crate::video::record(f)["file"].as_str().unwrap_or("none")) };
let mut hints = vec!["`confidence` prints both scores; `bench plan` writes the optional instructions for a person with the real board".to_string()];
if failed { hints.push("the run is complete without the video: fix what the line names and run `video build` (reverse-flow/video/build-log.txt has the tool's output)".into()); }
return end("complete", line, hints, &ran);
}
Action::NeedAi(name, lines) => return end("needs-ai", format!("AUTO NEEDS AI: {name}"), lines, &ran),
Action::Run(cmd, why) => (cmd, why),
};
// the same command again after it passed and changed nothing the plan reads: stop, never loop
if last.as_ref().is_some_and(|(c, ok, moved)| *ok && !*moved && *c == cmd) { return end("failed", format!("AUTO FAILED: `{}` passed and is still next: the flow is not moving", cmd.join(" ")), vec!["This is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()], &ran); }
if ran.len() >= max { return end("failed", format!("AUTO FAILED: {max} commands run without reaching an end"), vec![format!("next would be `{}`; run `auto` again to go on, or raise --max-steps", cmd.join(" "))], &ran); }
// a sample is drawn once per run of `auto`: asked for again, the answer is the auditor's to give
if cmd.first().map(String::as_str) == Some("audit") && ran.contains(&cmd.join(" ")) { return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), vec![format!("`{}` was drawn and is still what the review waits for: `audit status` shows every sample, `review pass` says what each needs. Finish the current sample with an auditor who did not produce this run, then run `auto` again.", cmd.join(" "))], &ran); }
let before = fingerprint(f, pcb_of().as_deref());
say(format!("> {} ({why})", cmd.join(" ")));
let out = std::process::Command::new(&exe).arg("--ai-thread").arg(thread).arg("--project").arg(project_dir).args(&cmd).output();
let (ok, text) = match out { Ok(o) => { let t = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr); let died = died(&o.status, &t); (o.status.success(), match died { Some(d) => format!("{t}\nERROR: {d}\n"), None => t }) } Err(e) => (false, format!("ERROR: could not start {}: {e}", exe.display())) };
if let Ok(mut lf) = std::fs::OpenOptions::new().create(true).append(true).open(f.join(LOG)) { use std::io::Write; let _ = writeln!(lf, "## {} > {}\n{text}", crate::ledger::now(), cmd.join(" ")); }
// a step that passed answers OK first; one that failed is known by its ERROR line, its own or the one `died` wrote for it
let head = text.lines().find(|l| if ok { l.starts_with("OK:") } else { l.starts_with("ERROR:") }).or_else(|| text.lines().find(|l| l.starts_with("OK:") || l.starts_with("ERROR:"))).unwrap_or("").to_string();
let died_line = !ok && head.starts_with("ERROR: the step ");
let hints: Vec<String> = text.lines().filter(|l| l.starts_with("Hint: ")).map(|l| l[6..].to_string()).collect();
say(format!(" {head}"));
ran.push(cmd.join(" "));
after(f, &cmd, ok);
let moved = fingerprint(f, pcb_of().as_deref()) != before;
if !ok {
let c: Vec<&str> = cmd.iter().map(String::as_str).collect();
match c.as_slice() {
// the photos' own layers and their score feed the review; the flow goes on without them and the review says so
// the video is a deliverable, not a gate: the reason is kept for the last line and the run completes
["bench", "plan"] => { remember(f, "benchPlanFailed", json!(true)); say(" not a gate: the run completes without the bench plan; `bench plan` tries again".into()); last = Some((cmd, false, moved)); continue; }
["video", "build"] => {
remember(f, "videoFailedAt", json!(crate::video::input_key(f)));
remember(f, "videoError", json!(head.trim_start_matches("ERROR: ").trim_start_matches("the video was not built: ")));
say(" not a gate: the run completes without the video, and the last line says so".into());
last = Some((cmd, false, moved)); continue;
}
["layers", "trace"] | ["check", "fidelity"] | ["nets", "draw"] => { say(" not a gate: the flow goes on, and the review counts the missing figure against the score".into()); last = Some((cmd, false, moved)); continue; }
// the review's record no longer matches the files (a bench result came in, a score moved): one more pass measures again
["check", "review"] if review_redos < 2 => { review_redos += 1; remember(f, "reviewRedo", json!(true)); say(" the recorded pass no longer matches the files: the review measures again".into()); last = Some((cmd, false, moved)); continue; }
// a holes board whose flip the holes cannot settle is settled by `scale --holes`
["check", "prep"] if !has(f, "scale.json") => return end("needs-ai", "AUTO NEEDS AI: scale".into(), { let mut v = hints.clone(); v.push("The photos' check waits for the scale to settle which way the board was turned over: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y], or scale --body <id>=<L>x<W> --source \"<datasheet>\". Then run `auto` again.".into()); v }, &ran),
// the build could not be written at all: what it says is the AI's to fix
["build"] => return end("needs-ai", "AUTO NEEDS AI: fix what `build` lists".into(), { let mut v = vec![head.clone()]; v.extend(hints.iter().take(40).cloned()); v.push("Then run `auto` again.".into()); v }, &ran),
// a failed check lists what to fix: that is the AI's input
["check", what, ..] => return end("needs-ai", format!("AUTO NEEDS AI: fix what `check {what}` lists"), { let mut v: Vec<String> = hints.iter().take(40).cloned().collect(); if hints.len() > 40 { v.push(format!("and {} more: run `check {what}`", hints.len() - 40)); } v.push("Then run `auto` again.".into()); v }, &ran),
["report"] => return end("needs-ai", "AUTO NEEDS AI: report.md".into(), { let mut v = hints.clone(); v.push("Correct reverse-flow/report.md, then run `auto` again.".into()); v }, &ran),
// a sample that cannot be drawn on this board is recorded as such: the category stays at its cap and the next pass no longer waits for it
["audit", "draw", ..] if head.contains("planted claims") => { say(" no sample can be drawn on this board: the category stays at its cap, and the review goes on without this audit".into()); last = Some((cmd, false, moved)); continue; }
["audit", "draw", ..] => return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), { let mut v = vec![head.clone()]; v.extend(hints.clone()); v.push("`audit status` shows every sample; finish the current one, then run `auto` again.".into()); v }, &ran),
_ => return end("failed", format!("AUTO FAILED: `{}`: {}", cmd.join(" "), head.trim_start_matches("ERROR: ")), { let mut v = hints; if head.contains("memory") { v.push("This step needs more memory than it was allowed: run it with nothing else running, or raise the limit it runs under. If it still fails, this is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()); } else if died_line { v.push("The step did not end by itself: this is a defect in the flow, not in the board. Report it with reverse-flow/auto-log.txt".into()); } v }, &ran),
}
}
last = Some((cmd, ok, moved));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("auto-test-{name}-{}", std::process::id())).join("reverse-flow");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("checks")).unwrap();
std::fs::create_dir_all(d.join("detect")).unwrap();
d
}
fn put(d: &Path, f: &str, v: Value) { if let Some(p) = d.join(f).parent() { std::fs::create_dir_all(p).unwrap(); } std::fs::write(d.join(f), v.to_string()).unwrap(); }
fn pass(d: &Path, check: &str, files: &[&str]) {
let m: serde_json::Map<String, Value> = files.iter().map(|f| (f.to_string(), json!(if check == "copper" && *f == "copper.json" { attrib::geometry_hash(&d.join(f)) } else { crate::sha256(&d.join(f)) }))).collect();
put(d, &format!("checks/{check}.ok"), json!({"check": check, "files": m}));
}
fn cmd(a: &Action) -> String { match a { Action::Run(c, _) => c.join(" "), Action::NeedAi(n, _) => format!("AI: {n}"), Action::Complete => "complete".into() } }
#[test]
fn the_plan_walks_the_early_steps_and_names_each_input_the_ai_owes() {
let d = dir("early"); let fl = flow::load();
put(&d, "state.json", json!({"photos": []}));
assert_eq!(cmd(&plan(&d, &fl)), "prep");
put(&d, "prep.json", json!({"photos": []}));
assert_eq!(cmd(&plan(&d, &fl)), "check prep");
pass(&d, "prep", &["prep.json"]);
// the scale takes a judgement: which body, from which datasheet
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: scale");
if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("scale --body")) && lines.iter().any(|l| l.contains("scale --holes"))); }
put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
assert_eq!(cmd(&plan(&d, &fl)), "detect parts");
put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}, {"id": "P002"}]}));
assert_eq!(cmd(&plan(&d, &fl)), "parts init");
// parts.json exactly as `parts init` left it: the crops have not been read yet
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));
after(&d, &["parts".into(), "init".into()], true);
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: parts.json");
if let Action::NeedAi(_, lines) = &a { assert!(lines[0].contains("reverse-flow/parts.json")); }
// once edited, the program goes on: fragments, pads, the check
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));
assert_eq!(cmd(&plan(&d, &fl)), "parts reconcile");
after(&d, &["parts".into(), "reconcile".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "parts snap");
after(&d, &["parts".into(), "snap".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
// a later edit to a snapped part (a value from the bench) does not move the parts again
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}, "value": "10k"}, {"ref": "R2", "footprint": "R_0402"}], "unassigned": [], "dismissed": [{"candidate": "P002", "why": "silk"}]}));
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
pass(&d, "parts", &["parts.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");
put(&d, "detect/blocks-proposal.json", json!({"groups": []}));
assert_eq!(cmd(&plan(&d, &fl)), "AI: blocks.json");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn the_plan_stops_once_for_datasheet_pins_and_goes_on_with_a_sheet_or_a_reasoned_no() {
let d = dir("datasheets"); let fl = flow::load();
put(&d, "state.json", json!({"photos": []}));
put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
put(&d, "detect/parts.json", json!({"candidates": []}));
// two chips whose markings were read, one that was not, a resistor: snapped already, so the check would be next
put(&d, "parts.json", json!({"parts": [
{"ref": "U1", "value": "XY1234", "marking": "XY1234", "fieldTags": {"value": "SEEN"}, "snap": {"method": "pads"}, "pin1": {"mark": "dot at the top left corner", "tag": "SEEN"}},
{"ref": "U2", "value": "LM000", "marking": "LM000", "fieldTags": {"value": "SEEN"}},
{"ref": "U3", "value": "regulator", "marking": "unreadable", "fieldTags": {"value": "UNKNOWN"}},
{"ref": "R1", "value": "10k", "marking": "103", "fieldTags": {"value": "SEEN"}}], "unassigned": [], "dismissed": []}));
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: datasheet pins");
if let Action::NeedAi(_, lines) = &a {
assert!(lines[0].contains("reverse-flow/datasheets/<ref>.pins.json") && lines[1].contains("\"available\": false"));
assert!(lines.iter().any(|l| l.starts_with("U1: XY1234")) && lines.iter().any(|l| l.starts_with("U2: LM000")));
assert!(!lines.iter().any(|l| l.starts_with("U3") || l.starts_with("R1")), "a chip nobody read and a resistor are not asked for");
}
// one answered "none available" with a reason: the other still waits
put(&d, "datasheets/U2.pins.json", json!({"part": "U2", "available": false, "reason": "the top mark fits three parts"}));
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: datasheet pins");
if let Action::NeedAi(_, lines) = &a { assert!(!lines.iter().any(|l| l.starts_with("U2"))); }
// a file that does not pass is the AI's to fix, with the reason
put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234"}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}]}));
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: datasheet pins");
if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("document.revision"))); }
// a sheet that passes: the program writes the pins into parts.json, then goes on to the check, and does not stop again
put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234", "revision": "B", "page": 3}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}, {"pad": "2", "name": "GND", "type": "ground"}]}));
assert_eq!(cmd(&plan(&d, &fl)), "datasheet apply");
dspins::apply(&d).unwrap();
assert_eq!(items::read_json(&d.join("parts.json")).unwrap()["parts"][0]["pins"]["1"]["name"], "VIN");
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
// part, then orientation, then pinout: a chip with a pinout and an inferred orientation is asked about, by name, before anything is built on it
let mut pj = items::read_json(&d.join("parts.json")).unwrap();
pj["parts"][0]["pin1"] = json!({"mark": "taken from the reading direction of the marking", "tag": "INFERRED"});
put(&d, "parts.json", pj.clone());
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: orientation of chips with a pinout");
if let Action::NeedAi(_, lines) = &a { assert!(lines.len() == 2 && lines[1].starts_with("U1 (package") && lines[1].contains("XY1234") && lines[1].contains("pin1: tagged INFERRED") && lines[1].contains("Look for a dot")); }
// settled, or said to be not determinable: the plan goes on either way
pj["parts"][0]["pin1"] = json!({"mark": "looked for a dot, a notch and a bevel: none", "tag": "UNKNOWN"});
put(&d, "parts.json", pj.clone());
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
pj["parts"][0]["pin1"] = json!({"mark": "dot at the top left corner", "tag": "SEEN"});
put(&d, "parts.json", pj);
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
// names the sheets give that attribution has not seen send the plan back to it
put(&d, "copper.json", json!({"from": "islands", "zones": [], "traces": [], "vias": []}));
put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));
put(&d, "nets.json", json!({"nets": []}));
put(&d, "detect/attribution.json", json!({}));
remember(&d, "netsAfterAttribute", json!(crate::sha256(&d.join("nets.json"))));
// the nets are built from the pinout first, then the copper is held against them
assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect pinout"));
let s = pinnets::build(&d).unwrap();
assert_eq!((s["pinoutNets"].as_u64(), s["pinoutNetsDatasheet"].as_u64()), (Some(2), Some(2)));
assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect attribute"));
after(&d, &["connect".into(), "attribute".into()], true);
assert!(attribution(&d).is_none());
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn attribution_comes_before_the_copper_check_and_an_edit_to_the_nets_sends_the_plan_back_by_itself() {
let d = dir("order"); let fl = flow::load();
put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));
put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
put(&d, "parts.json", json!({"parts": []})); pass(&d, "parts", &["parts.json"]);
put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "copper prep");
put(&d, "detect/domes.json", json!({"domes": []}));
assert_eq!(cmd(&plan(&d, &fl)), "copper trace");
let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "F.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});
put(&d, "copper.json", copper.clone());
// no net named by anyone and no nets.json: the photos are read for attribution, then attribution starts the nets itself
assert_eq!(cmd(&plan(&d, &fl)), "connect measure");
put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));
assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
put(&d, "nets.json", json!({"nets": []}));
put(&d, "detect/attribution.json", json!({}));
after(&d, &["connect".into(), "attribute".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
pass(&d, "copper", &["copper.json"]);
// built, checked and saved
put(&d, crate::photolayers::FILE, json!({}));
assert_eq!(cmd(&plan(&d, &fl)), "build");
std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();
// a board with no record of what its build left out is from an older build: built again once
assert_eq!(cmd(&plan(&d, &fl)), "build");
put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": [], "noFill": []}));
assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
pass(&d, "mimic", &["parts.json", "copper.json"]);
assert!(cmd(&plan(&d, &fl)).starts_with("snapshot --name mimic --dir "));
put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));
// the connect step never asks for the router
assert_eq!(cmd(&plan(&d, &fl)), "layers state");
assert!(!fl.steps.iter().find(|s| s.name == "connect").unwrap().done.iter().any(|x| x.contains("route")));
// nets.json gone missing: attribution is the step that writes it, not a check that would only fail on it
std::fs::remove_file(d.join("nets.json")).unwrap();
assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
put(&d, "nets.json", json!({"nets": []}));
after(&d, &["connect".into(), "attribute".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "layers state");
// the AI names pins from a datasheet: attribution runs again, which rewrites the nets in copper.json
put(&d, "nets.json", json!({"nets": [{"name": "VCC", "pins": ["U1.1"], "tag": "DATASHEET", "source": "datasheet p.3"}]}));
assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
let mut named = copper.clone(); named["zones"][0]["net"] = json!("VCC");
put(&d, "copper.json", named);
put(&d, "detect/attribution.json", json!({}));
after(&d, &["connect".into(), "attribute".into()], true);
// the copper check reads shapes and stays; what was built on the nets is redone without anyone asking
assert!(flow::exists(&d, "checks/copper.ok"));
assert_eq!(cmd(&plan(&d, &fl)), "build");
std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();
assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
pass(&d, "mimic", &["parts.json", "copper.json"]);
assert!(cmd(&plan(&d, &fl)).starts_with("snapshot"));
// every step up to the report done: the net pictures are drawn before the report is asked for, and again when the nets change
put(&d, "layers.json", json!({"candidates": [{"layers": 2}], "method": "routed"}));
for (c, files) in [("nets", vec!["nets.json", "parts.json"]), ("layers", vec!["layers.json", "nets.json", "copper.json"]), ("review", vec!["review.json"]), ("tests", vec!["tests.json"])] {
for x in &files { if !d.join(x).is_file() { put(&d, x, json!({})); } }
pass(&d, c, &files);
}
put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));
// the schematic belongs to the connect step: drawn once the nets and the layer statement are checked, then checked by itself
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));
assert_eq!(cmd(&plan(&d, &fl)), "schematic");
let sch = d.join("x.kicad_sch");
std::fs::write(&sch, "(kicad_sch)").unwrap();
put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string(), "sch": sch.display().to_string()}}));
assert_eq!(cmd(&plan(&d, &fl)), "check schematic");
pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));
// parts, blocks or nets newer than the sheets: the check is stale, the flow comes back, and the sheets are drawn again first
std::thread::sleep(std::time::Duration::from_millis(30));
put(&d, "blocks.json", json!({"blocks": [{"id": "a"}]}));
pass(&d, "blocks", &["blocks.json", "parts.json"]);
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));
assert_eq!(cmd(&plan(&d, &fl)), "schematic");
std::fs::write(&sch, "(kicad_sch )").unwrap();
assert_eq!(cmd(&plan(&d, &fl)), "check schematic");
pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));
assert_eq!(cmd(&plan(&d, &fl)), "nets draw");
std::fs::create_dir_all(d.join("nets")).unwrap(); std::fs::write(d.join("nets/index.md"), "x").unwrap();
assert_eq!(cmd(&plan(&d, &fl)), "report");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
/// A run that has passed its early steps and has traced, attributed copper: the copper check is next.
fn at_copper_check(name: &str) -> PathBuf {
let d = dir(name);
put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));
put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); for s in ["top", "bottom"] { std::fs::write(d.join(format!("board/{s}.jpg")), "x").unwrap(); }
put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}}]})); pass(&d, "parts", &["parts.json"]);
put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);
put(&d, "detect/domes.json", json!({"domes": []}));
let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});
put(&d, "copper.json", copper.clone());
put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));
put(&d, "nets.json", json!({"nets": []}));
put(&d, "detect/attribution.json", json!({}));
after(&d, &["connect".into(), "attribute".into()], true);
d
}
#[test]
fn a_face_recorded_as_not_read_sends_attribution_round_again_and_the_run_goes_on() {
let d = at_copper_check("notread"); let fl = flow::load();
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
// `check copper` found the bottom swallowed: it records the face and the step is done
let (faces, _) = crate::notread::split(vec!["bottom face: island 2 holds 117 of the 148 pins joined on this face (79 percent).".into()]);
put(&d, "checks/copper.ok", json!({"check": "copper", "files": {"copper.json": attrib::geometry_hash(&d.join("copper.json"))}, "summary": {"notRead": crate::notread::to_json(&faces)}}));
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("mimic"));
// the nets were made with that face's joins: they are made again without them, once
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "connect attribute");
if let Action::Run(_, why) = &a { assert!(why.contains("NOT READ")); }
after(&d, &["connect".into(), "attribute".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "layers trace");
// the copper traced again and passing: the record is gone with the stale check, and the nets are made with every face again
put(&d, "copper.json", json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [2, 0], [1, 1]]}], "traces": [], "vias": []}));
put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("copper"));
assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
after(&d, &["connect".into(), "attribute".into()], true);
put(&d, "detect/attribution.json", json!({"again": 1}));
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn a_part_turned_after_the_copper_was_read_has_its_joins_read_again() {
let d = at_copper_check("turned"); let fl = flow::load();
// `auto` traced and measured with the parts where they were
after(&d, &["copper".into(), "trace".into()], true);
after(&d, &["connect".into(), "measure".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
// a value read later moves no pad: nothing is read again
put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));
pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
// the connector turned by 180 degrees (board 1's J2): its 55 pads are joined to the copper again, then read again for attribution
put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 180.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));
pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "copper trace");
after(&d, &["copper".into(), "trace".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "connect measure");
after(&d, &["connect".into(), "measure".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "check copper");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn parts_are_snapped_even_when_the_parts_check_was_passed_by_hand() {
let d = dir("handcheck"); let fl = flow::load();
put(&d, "state.json", json!({"photos": []}));
put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}]}));
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [], "dismissed": []}));
after(&d, &["parts".into(), "init".into()], true);
// the AI completes parts.json and runs `check parts` itself: the step reads done
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [], "dismissed": []}));
pass(&d, "parts", &["parts.json"]);
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("blocks"));
// the pads are still placed first
assert_eq!(cmd(&plan(&d, &fl)), "parts snap");
put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}}], "unassigned": [], "dismissed": []}));
after(&d, &["parts".into(), "snap".into()], true);
// which made the hand-run check stale: the plan is back in the parts step, and goes on from there
assert_eq!(cmd(&plan(&d, &fl)), "check parts");
pass(&d, "parts", &["parts.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn a_restitch_sends_the_plan_back_to_prep_before_the_scale_is_asked_for() {
let d = dir("restitch"); let fl = flow::load();
put(&d, "state.json", json!({"photos": [], "intake": {"stitched": true}}));
put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.3.JPG"}));
put(&d, "prep.json", json!({"photos": []}));
after(&d, &["prep".into()], true);
pass(&d, "prep", &["prep.json", "stitch.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");
// `stitch --top <photo>`: the other face is the top now, and what was measured is of the old faces
put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.7.JPG"}));
assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("intake"), "the prep check is stale with the stitch");
assert_eq!(cmd(&plan(&d, &fl)), "prep");
after(&d, &["prep".into()], true);
assert_eq!(cmd(&plan(&d, &fl)), "prep", "a check that passed on the old stitch still says so until it is run again");
pass(&d, "prep", &["prep.json", "stitch.json"]);
assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn a_part_the_build_left_out_is_asked_for_by_name_and_a_new_footprint_file_builds_again() {
let d = at_copper_check("leftout"); let fl = flow::load();
pass(&d, "copper", &["copper.json"]);
put(&d, crate::photolayers::FILE, json!({}));
assert_eq!(cmd(&plan(&d, &fl)), "build");
let pcb = d.join("x.kicad_pcb");
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(&pcb, "(kicad_pcb)").unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
// the build could not load J2's footprint and said so in its record
let rows = crate::kicad_out::left_out_rows(&[format!("{}J2: footprint Lib:Edge not in KiCad's library ()", crate::kicad_out::LEFT_OUT_MARK)], rd(&d, "parts.json")["parts"].as_array().unwrap());
put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": rows}));
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "AI: a footprint the build can load");
if let Action::NeedAi(_, lines) = &a { assert!(lines[0].starts_with("J2: left out of the board") && lines[0].contains("Lib:Edge") && lines[0].contains("reverse-flow/lib/fp/Lib/Edge.kicad_mod"), "{lines:?}"); }
// the AI writes the file: newer than the board, so the board is built again without anyone asking
std::thread::sleep(std::time::Duration::from_millis(30));
put(&d, "lib/fp/Lib/Edge.kicad_mod", json!("(footprint)"));
let a = plan(&d, &fl);
assert_eq!(cmd(&a), "build");
if let Action::Run(_, why) = &a { assert!(why.contains("Lib:Edge")); }
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(&pcb, "(kicad_pcb )").unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": []}));
assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn a_step_that_dies_without_a_word_still_gets_a_reason() {
// board 11 under the memory guard: `connect measure` printed the allocator's line and was gone; `auto` said "AUTO FAILED: `connect measure`: "
let t = died_text(None, Some(6), "tracing the top face\nmemory allocation of 81460080 bytes failed\n");
assert!(t.starts_with("the step ran out of memory and stopped") && t.contains("memory allocation of 81460080 bytes failed"), "{t}");
let t = died_text(None, Some(9), "");
assert!(t.contains("killed (signal 9)") && t.contains("it printed nothing"), "{t}");
let t = died_text(Some(101), None, "thread 'main' panicked at src/x.rs:1:1:\nindex out of bounds");
assert!(t.contains("exit code 101") && t.contains("index out of bounds"), "{t}");
}
#[test]
fn the_video_follows_the_report_and_a_failed_build_does_not_hold_the_run() {
let d = dir("video");
// the plan after the last step, as `plan` calls it once every step is done: the bench plan first, then the video
assert_eq!(cmd(&video(&d)), "bench plan");
std::fs::create_dir_all(d.join("bench")).unwrap(); std::fs::write(d.join("bench/plan.md"), "x").unwrap();
assert_eq!(cmd(&video(&d)), "video build");
assert!(video_note(&d).is_empty());
// it failed on these files: recorded, not tried again, and said plainly
std::fs::write(d.join("checks/report.ok"), "{}").unwrap();
remember(&d, "videoFailedAt", json!(crate::video::input_key(&d)));
remember(&d, "videoError", json!("ffmpeg is not installed on this machine"));
assert_eq!(cmd(&video(&d)), "complete");
assert!(video_note(&d)[0].contains("was not built: ffmpeg is not installed"));
// the report passes again on changed files: the video is tried again
std::fs::write(d.join("checks/report.ok"), "{\"again\": 1}").unwrap();
assert_eq!(cmd(&video(&d)), "video build");
// built: complete, and `next` names the file
std::fs::create_dir_all(d.join("video")).unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(d.join("video/x-run.mp4"), "x").unwrap();
std::fs::write(d.join("video/video.json"), json!({"ok": true, "file": "reverse-flow/video/x-run.mp4"}).to_string()).unwrap();
assert_eq!(cmd(&video(&d)), "complete");
assert!(video_note(&d)[0].contains("reverse-flow/video/x-run.mp4"));
// every step of the flow file comes before it: the video is not a step, so `steps` and the flow window keep their eleven
assert!(!flow::load().steps.iter().any(|s| s.name == "video"));
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn a_review_waiting_for_audits_names_each_sheet_and_the_exact_command() {
let d = dir("review");
let scores = json!({"categories": {
"copper": {"heldByAudit": true, "audit": {"status": "incomplete", "sample": "abc123", "verdicts": 3, "items": 110}},
"vias": {"heldByAudit": true, "audit": {"status": "none", "sample": "def456", "verdicts": 0, "items": 57}},
"layers": {"heldByAudit": false, "audit": {"status": "none"}}}});
let lines = audits(&scores, &[]).unwrap();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("reverse-flow/audits/copper/sheet.md") && lines[0].contains("audit verdict --category copper --sample abc123 --file"));
assert!(lines[1].contains("--category vias --sample def456"));
// a voided sample is drawn again by the program before anyone is asked
let void = json!({"categories": {"copper": {"heldByAudit": true, "audit": {"status": "void"}}}});
assert_eq!(cmd(&audits(&void, &[]).err().unwrap()), "audit draw --category copper");
// and one too small to reach the target is extended
let small = json!({"categories": {"vias": {"heldByAudit": true, "audit": {"status": "scored", "strata": [{"audited": 6, "needForTarget": 35}]}}}});
assert_eq!(cmd(&audits(&small, &[]).err().unwrap()), "audit draw --category vias --n 35");
// once drawn, it is not drawn again before the next pass has looked
assert!(audits(&small, &["vias".to_string()]).unwrap().is_empty());
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
}