main
Caleb Publish 0.5.0 1984b40
//! A face whose copper the reader got wrong, and the honest way on.
//!
//! `check copper` judges the traced copper as a whole (`copperchecks`): one island holding two thirds of a face's pins, a face that
//! is all copper, levels that miss the photo's edges. Nothing a runner may supply changes such a reading: `copper trace` takes no
//! setting, and nobody edits copper by hand. Until 2026-11-11 the run stopped there for good, and never wrote its review, tests,
//! report, bench plan or video. Now the check records that face's copper as NOT READ, with its own message, in
//! `checks/copper.ok` (`summary.notRead`), and the run goes on with the failure carried everywhere a reader would look:
//!
//! - `connect attribute` takes no join from that face, so no net is claimed through it;
//! - the copper and connectivity scores count the face as failed, and no audit sample is drawn on it;
//! - the review lists it as a finding, `tests generate` writes a test for it, and the report, the net pictures, the schematic
//!   and the video say it in plain words.
//!
//! The record lives in the check's own file, so it goes stale with the copper: a new trace is judged afresh.
//!
//! Since copper round 3 the reader gives up on such a face itself at trace time (`faceread`: its islands are taken out, and the
//! accounting carries `unread` with why). That is the same thing found earlier, and it is carried the same way: `declared` reads
//! it, `check copper` writes it into the same record, and every sentence is the same. What only the check finds (one island with
//! most of a large part's pins) still comes through `split`. A face no photo shows at all is `missing`: NOT PHOTOGRAPHED.
use crate::{flow, items};
use serde_json::{json, Value};
use std::path::Path;

pub const SIDES: [(&str, &str); 2] = [("top", "F.Cu"), ("bottom", "B.Cu")];

/// `photographed` is false for a face no photo in the run shows: nothing was read there because there was nothing to read.
#[derive(Clone, Debug, PartialEq)]
pub struct Face { pub side: String, pub layer: String, pub messages: Vec<String>, pub photographed: bool }

/// The side a whole-face reader fault names: `copperchecks` starts each of its sentences with "<side> face:".
fn side_of(message: &str) -> Option<(&'static str, &'static str)> { SIDES.iter().copied().find(|(s, _)| message.starts_with(&format!("{s} face:"))) }

/// Split what `copperchecks` found into faces not read, one per side with every sentence about it. A sentence that names no face
/// stays an error: it is not this kind of fault.
pub fn split(errs: Vec<String>) -> (Vec<Face>, Vec<String>) {
    let (mut faces, mut rest): (Vec<Face>, Vec<String>) = (vec![], vec![]);
    for e in errs {
        match side_of(&e) {
            Some((side, layer)) => match faces.iter_mut().find(|f| f.side == side) { Some(f) => f.messages.push(e), None => faces.push(Face { side: side.into(), layer: layer.into(), messages: vec![e], photographed: true }) },
            None => rest.push(e),
        }
    }
    faces.sort_by_key(|f| SIDES.iter().position(|s| s.0 == f.side));
    (faces, rest)
}

pub fn to_json(faces: &[Face]) -> Value { json!(faces.iter().map(|f| json!({"side": f.side, "layer": f.layer, "messages": f.messages})).collect::<Vec<_>>()) }

fn from_json(v: &Value) -> Vec<Face> {
    v.as_array().into_iter().flatten().filter_map(|f| Some(Face { side: f["side"].as_str()?.to_string(), layer: f["layer"].as_str()?.to_string(), messages: f["messages"].as_array().into_iter().flatten().filter_map(|m| m.as_str().map(str::to_string)).collect(), photographed: true })).collect()
}

/// The faces `check copper` recorded as not read, while that check still stands for the copper as traced.
pub fn recorded(flow_dir: &Path) -> Vec<Face> {
    if !flow::exists(flow_dir, "checks/copper.ok") { return vec![]; }
    items::read_json(&flow_dir.join("checks/copper.ok")).map(|v| from_json(&v["summary"]["notRead"])).unwrap_or_default()
}

/// A face no photo shows: the frame is made (`scale` wrote the board image of one face) and the other face has no image. Board 11
/// of the 2026-11-10 set: the stitcher could not join the two photos of its other face, and the run died at `connect measure`
/// with "board/bottom.jpg is missing". Such a run finishes on the face it has, with the other recorded as NOT PHOTOGRAPHED.
pub fn missing(flow_dir: &Path) -> Vec<Face> {
    let has = |s: &str| flow_dir.join(format!("board/{s}.jpg")).is_file();
    if !flow_dir.join("scale.json").is_file() || !SIDES.iter().any(|(s, _)| has(s)) { return vec![]; }
    SIDES.iter().filter(|(s, _)| !has(s)).map(|(s, l)| Face { side: s.to_string(), layer: l.to_string(), photographed: false,
        messages: vec![format!("{s} face: no photo of this face is in the run (the photos given show one face, or the photos of the other could not be joined into it; stitch.json says which were left out)")] }).collect()
}

/// The faces the reader itself gave up on at trace time (`faceread`): it took their islands out and wrote why into the
/// accounting (`detect/copper-island-accounting.json` perFace[].unread). The same thing as a face `check copper` fails, found
/// earlier: one concept, one record, one set of words.
pub fn declared(flow_dir: &Path) -> Vec<Face> {
    let acc = items::read_json(&flow_dir.join("detect/copper-island-accounting.json")).unwrap_or(Value::Null);
    acc["perFace"].as_array().into_iter().flatten().filter_map(|f| {
        let (side, layer) = SIDES.iter().copied().find(|(s, _)| f["side"].as_str() == Some(s))?;
        Some(Face { side: side.into(), layer: layer.into(), messages: vec![format!("{side} face: {}", f["unread"].as_str()?)], photographed: true })
    }).collect()
}

/// Every face whose copper the run does not have: not photographed, given up on by the reader, or read and failed at the check.
pub fn faces(flow_dir: &Path) -> Vec<Face> {
    let mut v = missing(flow_dir);
    for f in declared(flow_dir).into_iter().chain(recorded(flow_dir)) { if !v.iter().any(|x| x.side == f.side) { v.push(f); } }
    v.sort_by_key(|f| SIDES.iter().position(|s| s.0 == f.side));
    v
}

/// What attribution was run against: "" when every face was read, else the sides, in order.
pub fn key(flow_dir: &Path) -> String { faces(flow_dir).iter().map(|f| f.side.clone()).collect::<Vec<_>>().join(",") }

pub fn skips(faces: &[Face], side: &str) -> bool { faces.iter().any(|f| f.side == side) }
pub fn skips_layer(faces: &[Face], layer: &str) -> bool { faces.iter().any(|f| f.layer == layer) }

/// The id tests and findings use for a face.
pub fn item_id(side: &str) -> String { format!("face:{side}") }
pub fn test_id(side: &str) -> String { format!("T-copper-not-read-{side}") }

/// One plain sentence per face, the same words in the check, the review, the report, the pictures and the video.
pub fn sentence(f: &Face) -> String {
    if !f.photographed { return format!("The {} face was NOT PHOTOGRAPHED: this run read one face only. No part, pour, track or net of the {} face is in it, no via is claimed (a via needs both faces to show it), and no join between the faces is claimed.", f.side, f.side); }
    format!("The copper of the {} face was NOT READ, so no pour, track, join or net on this face is claimed: its pins join nothing there, and their nets must come from the other face, a datasheet or the bench. Why: {}", f.side, f.messages.join(" "))
}

/// The short form, for a picture's banner and the video's caption.
pub fn short(f: &Face) -> String {
    if !f.photographed { return format!("{} face: NOT PHOTOGRAPHED; this run read one face only", f.side.to_uppercase()); }
    format!("{} face: copper NOT READ; nothing on this face is claimed", f.side.to_uppercase())
}

pub fn sentences(flow_dir: &Path) -> Vec<String> { faces(flow_dir).iter().map(sentence).collect() }

/// The line `check copper` answers with when it records a failed reading, or `None` when every face was read.
pub fn headline(summary: &Value) -> Option<String> {
    let f = from_json(&summary["notRead"]);
    if f.is_empty() { return None; }
    let sides: Vec<String> = f.iter().map(|x| x.side.clone()).collect();
    Some(format!("check copper: the copper of the {} face is NOT READ, which is recorded as a failure, not passed. Only a fix to the reader or a better photo can change that, so the run goes on with the failure carried: no net is claimed through that face, the copper and connectivity scores count it as failed, and the review, tests, report, net pictures and video say so", sides.join(" and the ")))
}

/// The test for each face not read: what a person with the board, or a fixed reader, would do to settle it.
pub fn tests(flow_dir: &Path) -> Vec<Value> {
    faces(flow_dir).iter().map(|f| if !f.photographed { json!({
        "id": test_id(&f.side), "settles": [item_id(&f.side)], "generated": true,
        "method": format!("The {} face was not photographed. Photograph it as the other face was (close-up tiles that overlap by a third, and one photo of the whole face), add the photos to the run and start again from `init --photos`, so both faces are read and the vias between them can be found", f.side),
        "equipment": "camera with even light",
        "expected": format!("`stitch` builds both faces, and `copper trace` and `check copper` pass on the {} face", f.side),
        "ifNot": "the run stays a one-face run: parts, copper and nets of the other face, every via and every join between the faces come only from the bench",
    }) } else { json!({
        "id": test_id(&f.side), "settles": [item_id(&f.side)], "generated": true,
        "method": format!("The copper of the {} face was not read from the photos ({}). Photograph that face again, square on, evenly lit and filling the frame, and run `copper trace` again, on a build whose reader reads it and passes `check copper`; or, with the board in hand, trace each track of that face with a continuity meter from pad to pad", f.side, f.messages.first().map(String::as_str).unwrap_or("")),
        "equipment": "camera with even light, or DMM in continuity mode with fine probes",
        "expected": format!("`check copper` passes on the {} face with no island holding most of its pins, and each pad joins only the pads its track reaches", f.side),
        "ifNot": "the face stays NOT READ: nothing on it may be claimed, and its nets come only from the bench",
    }) }).collect()
}

/// The ceiling both the copper and the connectivity score carry while a face is not read: the share of photographed faces whose
/// copper passed. One of two faces failed is 50, both is 0.
pub fn ceiling(flow_dir: &Path) -> Option<(f64, String)> {
    let f = faces(flow_dir);
    if f.is_empty() { return None; }
    let read = SIDES.len().saturating_sub(f.len());
    Some((100.0 * read as f64 / SIDES.len() as f64, format!("{read} of the board's {} faces have copper that was read and passed `check copper`; without: {}", SIDES.len(), f.iter().map(|x| format!("{} ({})", x.side, if x.photographed { x.messages.first().map(String::as_str).unwrap_or("") } else { "not photographed" })).collect::<Vec<_>>().join("; "))))
}

pub const CEILING: &str = "faces whose copper was read";
pub const RAISE: &str = "only a fix to the copper reader, or a photo (or a better photo) of that face, raises this: then `copper trace` and `check copper` again. Nothing a runner may type changes it";

/// The section the report tables carry, and that report.md must keep word for word (`required`).
pub fn report_section(flow_dir: &Path) -> String {
    let f = faces(flow_dir);
    if f.is_empty() { return String::new(); }
    let mut md = if f.iter().all(|x| !x.photographed) { "## A face not photographed\n\n" } else { "## Copper not read\n\n" }.to_string();
    for x in &f { md += &format!("- {}\n", required(x)); }
    md += "\nWhat follows from it, everywhere in this report: no net is claimed through that face, the copper and connectivity scores count the face as failed, and no via or join between the faces rests on it. Where a face was read and failed, its copper is left out of the net pictures and marked there, and the KiCad copy still holds what the reader drew on that face, which is not to be trusted. ";
    md += &format!("Tests: {}.\n\n", f.iter().map(|x| test_id(&x.side)).collect::<Vec<_>>().join(", "));
    for x in &f { for m in &x.messages { md += &format!("> {m}\n\n"); } }
    md
}

/// The sentence report.md must carry for a face, word for word.
pub fn required(f: &Face) -> String {
    if !f.photographed { return format!("The {} face was NOT PHOTOGRAPHED: this run read one face only, and nothing on the {} face, no via and no join between the faces is claimed.", f.side, f.side); }
    format!("The copper of the {} face was NOT READ, and nothing on that face is claimed.", f.side)
}

#[cfg(test)]
mod tests_notread {
    use super::*;

    fn dir(name: &str) -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!("notread-{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("board")).unwrap();
        d
    }

    #[test]
    fn a_whole_face_fault_becomes_a_face_not_read_and_anything_else_stays_an_error() {
        let (f, rest) = split(vec![
            "bottom face: island 2 holds 117 of the 148 pins joined on this face (79 percent). No one conductor holds two thirds".into(),
            "zone z9: no polygon_mm: sketch it".into(),
            "bottom face: island 2 joins 20 of the 24 pins of J2.".into(),
            "top face: 97 percent of what can be seen read as copper.".into()]);
        assert_eq!(f.len(), 2);
        assert_eq!((f[0].side.as_str(), f[0].layer.as_str(), f[0].messages.len()), ("top", "F.Cu", 1));
        assert_eq!((f[1].side.as_str(), f[1].layer.as_str(), f[1].messages.len()), ("bottom", "B.Cu", 2));
        assert_eq!(rest, vec!["zone z9: no polygon_mm: sketch it".to_string()]);
        assert!(headline(&json!({"notRead": to_json(&f)})).unwrap().contains("top and the bottom face"));
        assert!(headline(&json!({"zones": 3})).is_none());
    }

    #[test]
    fn the_record_lives_in_the_check_and_goes_stale_with_the_copper() {
        let d = dir("record");
        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}]}).to_string()).unwrap();
        for s in ["top", "bottom"] { std::fs::write(d.join(format!("board/{s}.jpg")), "x").unwrap(); }
        std::fs::write(d.join("scale.json"), "{}").unwrap();
        assert!(faces(&d).is_empty() && key(&d).is_empty() && ceiling(&d).is_none() && report_section(&d).is_empty() && tests(&d).is_empty());
        let (f, _) = split(vec!["bottom face: island 9 holds 35 of the 43 pins joined on this face (81 percent).".into()]);
        let g = crate::attrib::geometry_hash(&d.join("copper.json"));
        std::fs::write(d.join("checks/copper.ok"), json!({"check": "copper", "files": {"copper.json": g}, "summary": {"notRead": to_json(&f)}}).to_string()).unwrap();
        assert_eq!(faces(&d), f);
        assert_eq!(key(&d), "bottom");
        assert!(skips(&faces(&d), "bottom") && !skips(&faces(&d), "top") && skips_layer(&faces(&d), "B.Cu"));
        // one of two faces failed: both scores carry 50, with the check's own words
        let (v, why) = ceiling(&d).unwrap();
        assert_eq!(v, 50.0);
        assert!(why.contains("1 of the board's 2 faces") && why.contains("island 9 holds 35 of the 43"), "{why}");
        // the test and the report name the face and settle an id `check tests` knows
        let t = tests(&d);
        assert_eq!(t[0]["id"], "T-copper-not-read-bottom"); assert_eq!(t[0]["settles"][0], "face:bottom");
        let md = report_section(&d);
        assert!(md.contains(&required(&f[0])) && md.contains("T-copper-not-read-bottom") && md.contains("island 9 holds 35 of the 43"));
        assert!(sentence(&f[0]).contains("NOT READ") && short(&f[0]).starts_with("BOTTOM face"));
        // the copper traced again: the record no longer stands, and the new copper is judged afresh
        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z", "polygon_mm": [[0, 0], [2, 0], [1, 1]]}]}).to_string()).unwrap();
        assert!(faces(&d).is_empty());
        // the reader gave the face up itself at trace time (`faceread`): the same face, the same words, before any check has run
        std::fs::create_dir_all(d.join("detect")).unwrap();
        std::fs::write(d.join("detect/copper-island-accounting.json"), json!({"perFace": [{"side": "top", "copperShare": 0.4}, {"side": "bottom", "unread": "island 9 held 35 of the 43 pins joined on this face (81 percent)"}]}).to_string()).unwrap();
        let g = faces(&d);
        assert_eq!(g.len(), 1);
        assert_eq!((g[0].side.as_str(), g[0].photographed), ("bottom", true));
        assert_eq!(g[0].messages, vec!["bottom face: island 9 held 35 of the 43 pins joined on this face (81 percent)".to_string()]);
        assert_eq!(required(&g[0]), required(&f[0]), "one concept: the report's sentence is the same whoever found the face unreadable");
        assert_eq!(ceiling(&d).unwrap().0, 50.0);
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_face_with_no_photo_is_recorded_as_not_photographed_once_the_frame_is_made() {
        let d = dir("onefaced");
        std::fs::write(d.join("board/top.jpg"), "x").unwrap();
        // before the frame exists nothing is said: the photos are not sorted yet
        assert!(faces(&d).is_empty());
        std::fs::write(d.join("scale.json"), "{}").unwrap();
        let f = faces(&d);
        assert_eq!(f.len(), 1);
        assert_eq!((f[0].side.as_str(), f[0].layer.as_str(), f[0].photographed), ("bottom", "B.Cu", false));
        assert_eq!(key(&d), "bottom");
        assert_eq!(ceiling(&d).unwrap().0, 50.0);
        assert!(ceiling(&d).unwrap().1.contains("bottom (not photographed)"));
        assert!(sentence(&f[0]).contains("NOT PHOTOGRAPHED") && sentence(&f[0]).contains("no via is claimed") && !sentence(&f[0]).contains("NOT READ"));
        assert!(required(&f[0]).contains("NOT PHOTOGRAPHED") && report_section(&d).starts_with("## A face not photographed"));
        let t = tests(&d);
        assert_eq!(t[0]["settles"][0], "face:bottom"); assert!(t[0]["method"].as_str().unwrap().contains("not photographed"));
        // it is not something `check copper` recorded, so that check's answer does not name it
        assert!(recorded(&d).is_empty());
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }
}