Files
essim/tests/test_analysis.cpp
François 90502c0762 Dashboard + palette + analyze screen; consolidated categorization rules.
UI restructuring:

- Dashboard (`screen_dashboard.cpp`, `screen_idx = 6`) is the new home
  screen at boot. Reads Overview / Health / Analysis / Modules from
  the current System every frame; per-module rows list parts grouped
  by `connector_type` and a Power/Gnd inference summary (yellow when
  any name-Power signal is refuted). Scrollable via PgUp/PgDn/Home/End.
  Letter shortcuts: `c`=console, `s`=search, `p`=plug (alias of
  connect), `t`=set-type, `e`=explore, `n`=net, `a`=analyze, `q`=quit.
- Global Ctrl-P palette (`screen_palette.cpp`) — fuzzy-finds over
  registered commands + module / signal names. Activation runs the
  bare command or jumps to the matching screen with state seeded.
- Unified analyze screen (`screen_analyze.cpp`, `screen_idx = 7`):
  tabbed layout (`Issues / Groups / Types`), Tab or ←→ to switch
  tabs, ↑/↓ to navigate the focused list. Replaces the previous
  shell-bouncing `[v]erify` shortcut — `verify` content is now in
  the Issues tab. Types tab attaches the decision rationale to each
  signal row (fan-out / voltage / hard floor).
- Context help panel: `RenderHelpPanel(title, entries)` in
  `tui_helpers.{hpp,cpp}` rendered on the right of every screen.
- Console (former "log") rename: screen 0 is `[c]onsole` in the UI
  and "console" in its help-panel title. The underlying screen and
  the shell prompt are unchanged.
- Esc from any non-home screen returns to the dashboard. The
  dashboard itself swallows Esc; quit via `q` / the `quit` command.
  `quit` now calls `screen_ptr->Exit()` directly so it works from
  any screen including via the palette.

Signal type inference:

- `Signal::type` defaults to `Other` — auto-inference no longer
  happens at construction.
- `infer_signal_types(System*)` is called at the end of every load.
  Three rules: GndShield from name alone; Power requires name match
  + a hard fan-out floor (< 3 pins = always Other, regardless of
  name or voltage) + at least one positive structural signal
  (fan-out ≥ 4 OR voltage pattern in the name like `3V3`, `5V`).
- Thresholds exposed in `analysis.hpp` (`POWER_FANOUT_HARD_FLOOR`,
  `POWER_FANOUT_CONFIRM_MIN`, `has_voltage_pattern`) so the analyze
  screen can render the same rationale without duplicating logic.
- `set-signal-type` still wins; save/restore round-trips the type.

Analysis groups & anomalies:

- New `GroupKind::DiffBus` — ≥ 2 diff pairs sharing the same
  outer-stem with consecutive integer indices are aggregated into a
  single bus (`MDI[0..3]_P/N`). `MDI0` and `PCIE_TX_0` index forms
  both accepted. Solo pairs under a bus-able stem fall back to
  `DiffPair`.
- New `AnomalyKind::DiffBusGap` for missing lanes.

Documentation:

- `DESIGN.md`: dedicated "Categorization rules (normative)" section
  consolidating signal type, NC origin, signal groups, anomalies,
  component kind, and connector wiring rules with exact thresholds
  and decision order.
- `doc/user/analysis.md` (new): user-facing version of the same
  rules in plain language. Linked from `doc/user/index.md`.

Tests: +6 new cases (62 total). Adjusted `test_persist.cpp` to set
the signal type explicitly in the fixture (no more auto-inference).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:23:33 +02:00

298 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <doctest/doctest.h>
#include "system/analysis.hpp"
#include "system/modules.hpp"
#include "system/parts.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <memory>
#include <string>
namespace {
// Add a signal of the given name to a module, plus a single pin on a
// helper part so the signal is not stripped by `drop_singleton_signals`
// when callers exercise that path.
void add_signal(Module *mod, Part *prt, const std::string &sig_name) {
Pin *pin = new Pin("p_" + sig_name);
prt->add(pin);
Signal *s = mod->signals->merge(sig_name);
s->add(pin);
pin->connect(s);
}
} // namespace
TEST_CASE("analyze detects diff pairs and reports `_P` orphans only") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "GETH_DA_P");
add_signal(m, p, "GETH_DA_N");
add_signal(m, p, "PCIE_RX_P"); // orphan: _P only → reported
add_signal(m, p, "USB_DM"); // not a diff pair (no _P/_N)
add_signal(m, p, "lower_p");
add_signal(m, p, "lower_n"); // lowercase still matches
add_signal(m, p, "RESET_N"); // _N only → NOT reported (active-low)
add_signal(m, p, "BOOTMODE_N"); // _N only → NOT reported
AnalysisReport r = analyze_system(sys.get());
int dp = 0, orphans = 0;
for (const auto &g : r.groups)
if (g.kind == GroupKind::DiffPair) ++dp;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffPairOrphan) ++orphans;
CHECK(dp == 2); // GETH_DA + lower
CHECK(orphans == 1); // only PCIE_RX_P
}
TEST_CASE("analyze aggregates diff pairs into a diff bus by trailing index") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "MDI0_P"); add_signal(m, p, "MDI0_N");
add_signal(m, p, "MDI1_P"); add_signal(m, p, "MDI1_N");
add_signal(m, p, "MDI2_P"); add_signal(m, p, "MDI2_N");
add_signal(m, p, "MDI3_P"); add_signal(m, p, "MDI3_N");
// Underscore-separated index variant.
add_signal(m, p, "PCIE_TX_0_P"); add_signal(m, p, "PCIE_TX_0_N");
add_signal(m, p, "PCIE_TX_1_P"); add_signal(m, p, "PCIE_TX_1_N");
// A lonely pair under a bus-able stem (only one index) must stay DiffPair.
add_signal(m, p, "USB3_TX_P"); add_signal(m, p, "USB3_TX_N");
AnalysisReport r = analyze_system(sys.get());
int dp = 0, db = 0;
bool mdi_found = false, pcie_found = false;
for (const auto &g : r.groups) {
if (g.kind == GroupKind::DiffPair) ++dp;
if (g.kind == GroupKind::DiffBus) ++db;
if (g.kind == GroupKind::DiffBus && g.label.find("MDI[") == 0) {
mdi_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 3);
CHECK(g.members.size() == 8); // 4 pairs × 2 signals
}
if (g.kind == GroupKind::DiffBus && g.label.find("PCIE_TX_[") == 0) {
pcie_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 1);
CHECK(g.members.size() == 4);
}
}
CHECK(db == 2);
CHECK(dp == 1); // USB3_TX kept as solo DiffPair (single index — degenerate bus)
CHECK(mdi_found);
CHECK(pcie_found);
}
TEST_CASE("analyze flags a diff bus with a missing lane") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "LANE0_P"); add_signal(m, p, "LANE0_N");
add_signal(m, p, "LANE1_P"); add_signal(m, p, "LANE1_N");
// LANE2 missing
add_signal(m, p, "LANE3_P"); add_signal(m, p, "LANE3_N");
AnalysisReport r = analyze_system(sys.get());
int gaps = 0;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffBusGap) ++gaps;
CHECK(gaps == 1);
}
TEST_CASE("analyze detects buses with bracketed and underscore forms") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "DATA[0]");
add_signal(m, p, "DATA[1]");
add_signal(m, p, "DATA[2]");
add_signal(m, p, "ADDR_0");
add_signal(m, p, "ADDR_1");
add_signal(m, p, "STANDALONE");
AnalysisReport r = analyze_system(sys.get());
int buses = 0;
bool data_found = false, addr_found = false;
for (const auto &g : r.groups) {
if (g.kind != GroupKind::Bus) continue;
++buses;
if (g.label.find("DATA") == 0) {
data_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 2);
CHECK(g.members.size() == 3);
} else if (g.label.find("ADDR_") == 0) {
addr_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 1);
CHECK(g.members.size() == 2);
}
}
CHECK(buses == 2);
CHECK(data_found);
CHECK(addr_found);
}
TEST_CASE("analyze flags bus gaps") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "D[0]");
add_signal(m, p, "D[1]");
add_signal(m, p, "D[3]"); // gap at 2
add_signal(m, p, "D[5]"); // gap at 4
AnalysisReport r = analyze_system(sys.get());
int gaps = 0;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::BusGap) ++gaps;
CHECK(gaps == 1); // one bus, one anomaly listing all missing indices
}
TEST_CASE("analyze ignores single-suffix \"buses\" and pure numbers") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "CLK_100MHZ"); // not a bus (single signal)
add_signal(m, p, "GND"); // no digit suffix
AnalysisReport r = analyze_system(sys.get());
for (const auto &g : r.groups)
CHECK(g.kind != GroupKind::Bus);
}
TEST_CASE("analyze rejects digits not preceded by `_` (false-positive guard)") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// GETH_01_VDD12, GETH_01_VDD13 look like a bus with stem "GETH_01_VDD"
// but there is no underscore before the digits — must NOT be detected.
add_signal(m, p, "GETH_01_VDD12");
add_signal(m, p, "GETH_01_VDD13");
add_signal(m, p, "GETH_01_VDD14");
AnalysisReport r = analyze_system(sys.get());
for (const auto &g : r.groups)
CHECK(g.kind != GroupKind::Bus);
}
TEST_CASE("analyze skips internal `$xxx` Mentor names") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "$N123");
add_signal(m, p, "$N124");
add_signal(m, p, "$N125");
add_signal(m, p, "$SIG_P");
add_signal(m, p, "$SIG_N");
AnalysisReport r = analyze_system(sys.get());
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
}
TEST_CASE("analyze on empty / null system") {
AnalysisReport r = analyze_system(nullptr);
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
auto sys = std::make_unique<System>();
r = analyze_system(sys.get());
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
}
TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
auto fan_out = [&](Signal *s, int n) {
for (int i = 0; i < n; ++i) {
Pin *pin = new Pin(s->name + "_" + std::to_string(i));
p->add(pin); s->add(pin); pin->connect(s);
}
};
Signal *p_3v3 = m->signals->merge("PWR_3V3"); fan_out(p_3v3, 3); // voltage + ≥ floor → Power
Signal *vcc = m->signals->merge("VCC"); fan_out(vcc, 5); // fan-out ≥ 4 → Power
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // < 3 → hard floor → Other
Signal *pwr_2 = m->signals->merge("PWR_2"); fan_out(pwr_2, 2); // < 3 → hard floor → Other
Signal *gnd = m->signals->merge("GND"); fan_out(gnd, 1); // gnd: name alone
Signal *clk = m->signals->merge("CLK_50MHZ"); fan_out(clk, 3); // not power-ish → Other
auto st = infer_signal_types(sys.get());
CHECK(st.power == 2); // PWR_3V3, VCC
CHECK(st.gnd == 1); // GND (name alone)
CHECK(st.kept_other == 2); // PWR_OK, PWR_2 below the hard floor
CHECK(p_3v3->type == SignalType::Power);
CHECK(vcc->type == SignalType::Power);
CHECK(gnd->type == SignalType::GndShield);
CHECK(pwr_ok->type == SignalType::Other);
CHECK(pwr_2->type == SignalType::Other);
CHECK(clk->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: fan-out hard floor overrides voltage in name") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// VS_3V3 has a voltage pattern that would normally confirm Power, but
// with only 2 pins it must still drop to Other because of the hard floor.
Signal *s = m->signals->merge("VS_3V3");
Pin *p1 = new Pin("p1"); p->add(p1); s->add(p1); p1->connect(s);
Pin *p2 = new Pin("p2"); p->add(p2); s->add(p2); p2->connect(s);
auto st = infer_signal_types(sys.get());
CHECK(st.power == 0);
CHECK(st.kept_other == 1);
CHECK(s->type == SignalType::Other);
}
TEST_CASE("Signal ctor defaults type to Other (no auto-inference)") {
Signal s("VCC");
CHECK(s.type == SignalType::Other);
}
TEST_CASE("analyze scopes detection per module (no cross-module merge)") {
auto sys = std::make_unique<System>();
Module *m1 = sys->modules()->merge("M1");
Module *m2 = sys->modules()->merge("M2");
Part *p1 = new Part("U1"); m1->add(p1);
Part *p2 = new Part("U1"); m2->add(p2);
add_signal(m1, p1, "SIG_P");
add_signal(m2, p2, "SIG_N"); // matching half lives in another module
AnalysisReport r = analyze_system(sys.get());
int dp = 0, orphans = 0;
for (const auto &g : r.groups)
if (g.kind == GroupKind::DiffPair) ++dp;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffPairOrphan) ++orphans;
CHECK(dp == 0);
CHECK(orphans == 1); // only the `_P` side is reported (active-low rule)
}