Signal analysis pass (analyze), NC tests, DESIGN.md catch-up.

- New `src/system/analysis.{hpp,cpp}` — stateless post-processing pass
  `analyze_system(System*) → AnalysisReport`. Per-module detection of
  signal groups and anomalies; pure read, re-runnable.
  - Groups: diff pairs (`*_P` / `*_N`, case-insensitive), buses
    (`NAME[N]` or strict `NAME_N` — the `_` before digits is required
    so names like `GETH_01_VDD12` are not misread as a bus).
  - Anomalies: `DiffPairOrphan` (asymmetric: only `_P` without `_N` is
    reported — `_N` alone is overloaded with active-low semantics and
    floods the output with false positives), `BusGap` (missing index
    inside a detected `[lo..hi]`).
  - Noise filters: signals starting with `$` (Mentor internals) are
    skipped wholesale.
- New `analyze` shell command — prints groups sorted by module +
  label, then anomalies. Sized for the upcoming dashboard.
- `tests/test_analysis.cpp` — 8 cases covering both detectors, false-
  positive guards (no-underscore digits, `$`-prefixed internals), and
  per-module scoping.
- `tests/test_nc_origin.cpp` — completes the prior NC-tagging commit
  with round-trip + drop_singleton_signals coverage.
- DESIGN.md updated: layout entry for `analysis.{hpp,cpp}` and new
  section explaining the pass; NC-origin paragraph aligned with the
  actual tag semantics and the verify three-pass summary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:42:58 +02:00
parent 280526304d
commit 5e89b33088
6 changed files with 598 additions and 3 deletions

View File

@@ -35,6 +35,7 @@ src/
pin_role.{hpp,cpp} pin_role(kind, name), pin_layout(kind),
FillPartFromLayout(part, kind)
nets.{hpp,cpp} find_net / compute_all_nets / net_type_consistent
analysis.{hpp,cpp} analyze_system → AnalysisReport (diff pairs, buses, anomalies)
persist.{hpp,cpp} save / restore (tab-delimited)
system.{hpp,cpp} System: owns Modules + Connections, exposes Load()
imports/ -- adapters that populate the domain
@@ -54,6 +55,7 @@ src/
screen_settype.cpp BuildSettypeScreen
screen_explore.cpp BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable)
screen_net.cpp BuildNetScreen (BFS over connections from a starting (module, signal))
screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to net + explore via Modal())
doc/classes.puml -- PlantUML class diagram
```
@@ -88,7 +90,7 @@ Built-in commands: `new`, `set`, `load`, `duplicate`, `save`, `restore`, `source
`duplicate <source> <newname>` deep-copies a module: signals (with type overrides), parts (with `connector_type` and `kind`), pins (with `expected_signal_type`), and rewires each pin to the equivalent same-named signal in the new module. Connections are NOT copied (they're cross-module topology).
`script-save <file>` writes a replay-ready script of every command issued since the last `new` (the `recorded` buffer is cleared inside the `new` action). The buffer is appended to inside `Finalize()` after the action runs, with a denylist of commands that aren't useful in a replay: `clear`, `help`, `quit`, `exit`, `source`, `script-save`. Note the `source` exclusion: when a script is sourced, the *individual lines inside* the script are recorded (because each goes through `Finalize`), so the saved script reproduces the same end state without the indirection.
`script-save <file>` writes a replay-ready script of every command issued since the last `new` (the `recorded` buffer is cleared inside the `new` action). The buffer is appended to inside `Finalize()` after the action runs, with a denylist of commands that aren't useful in a replay: `clear`, `help`, `quit`, `exit`, `source`, `script-save`. **Additionally, a bare invocation of an `interactive` command is skipped** (`opens_screen = spec.interactive && args.empty()`) — those open a full-screen mode rather than mutating state. Mutating actions taken *inside* a screen record their own canonical line (e.g. the signal-type popup pushes `set-signal-type <m> <s> <t>`). Note the `source` exclusion: when a script is sourced, the *individual lines inside* the script are recorded (because each goes through `Finalize`), so the saved script reproduces the same end state without the indirection.
`source <file>` reads a script line by line and feeds each line through `Submit()`. While the script is running, `in_source = true` is set on the `Tui` and:
- `Dispatch` / `Finalize` skip writing to memory + on-disk history.
@@ -96,7 +98,7 @@ Built-in commands: `new`, `set`, `load`, `duplicate`, `save`, `restore`, `source
Pending prompts (from incomplete inline commands) are NOT considered interactive and are filled by subsequent script lines, the way you'd expect. Lines starting with `#` and blank lines are skipped; leading/trailing whitespace is trimmed; `~/` is expanded.
`save` / `restore` (`src/system/persist.{hpp,cpp}`): tab-delimited line format, no extra deps. Tags: `M` (module), `P` (part with `connector_type`), `N` (pin → signal name; empty = NC), `S` (signal → type override; only emitted for non-default), `C` (connection header with endpoints + `transform_name`), `W` (wire pair within the current connection).
`save` / `restore` (`src/system/persist.{hpp,cpp}`): tab-delimited line format, no extra deps. Tags: `M` (module), `P` (part with `connector_type`), `N` (pin → signal name; empty = NC; optional 4th field carries `nc_origin_tag()`: `U` = ImportedUnconnected, `D` = DroppedSingleton — omitted when the pin has a signal or when origin is `None`), `S` (signal → type override; only emitted for non-default), `C` (connection header with endpoints + `transform_name`), `W` (wire pair within the current connection). The 4th N field is backward-compatible: pre-existing snapshots without it restore with `nc_origin = None`.
**Signals** carry a `type` (`SignalType::Power | GndShield | Other`) auto-inferred from the name in `Signal::Signal` via `infer_signal_type` (heuristic: GND/GROUND/SHIELD/CHASSIS → GndShield; PWR/VCC/VDD/VEE/VSS/VBAT/VS_/VS3_*/+/- prefixes → Power; else Other). Override with `set-signal-type <module> <signal> <power|gnd|other>`. The explore screen shows the type in the signal detail header.
@@ -104,7 +106,16 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
**Connector pin layout (preparation)**: `pin_layout(connector_type)` returns the canonical full pin-name list for a known connector kind, and `FillPartFromLayout(part, kind)` materialises NC pins for any layout position absent from the imported netlist. `set-type` calls it after setting `connector_type` (no-op today since `pin_layout` is a stub returning `{}` for everything — populate alongside `vpx_3u_role`). End-to-end chain in place: `set-type → FillPartFromLayout → pin_role`.
**`verify` (two passes)**: walks all typed pins and reports local mismatches between `expected_signal_type` and the actual signal type, AND walks all bridged nets reporting Power↔GndShield inconsistencies. `net <module> <signal>` prints the BFS-reached `(module, signal)` set with types and an `[INCONSISTENT]` flag.
**`verify` (three passes)**: (1) walks all typed pins and reports local mismatches between `expected_signal_type` and the actual signal type; (2) walks all bridged nets reporting Power↔GndShield inconsistencies; (3) prints a single-line orphan summary `N orphan pin(s) at import (X imported NC, Y dropped singleton)`. The orphan pass filters out pins that appear in any `Connection::pin_map` — those are bridged to a real signal on the peer module (typically `FillIdentityNCs`-materialised) and not real NCs at system level. `net <module> <signal>` prints the BFS-reached `(module, signal)` set with types and an `[INCONSISTENT]` flag.
**`analyze` (post-processing pass)**: `analyze_system(System*) → AnalysisReport` (`src/system/analysis.{hpp,cpp}`) is a stateless read-only pass that detects structural signal groups and anomalies. Per-module (signals are module-scoped):
- **Diff pairs**: signal names ending `_P` / `_N` (case-insensitive) grouped by stem. Both halves present → `SignalGroup{kind=DiffPair}`.
- **Buses**: two accepted forms — bracketed `NAME[N]` or strict-underscore `NAME_N`. The strict `_` rule before the digits is what avoids matching names like `GETH_01_VDD12` (no `_` before `12`). A stem with ≥ 2 entries becomes `SignalGroup{kind=Bus, lo, hi}`.
- **Anomalies** detected: `DiffPairOrphan` and `BusGap` (missing index inside `[lo..hi]`). The diff-pair orphan reporter is **asymmetric on purpose**: only `_P` without `_N` is reported, because `_N` is overloaded with active-low semantics (`RESET_N`, `BOOTMODE_N`) and reporting both directions floods the output with false positives.
- **Filters** to keep noise low: signals starting with `$` are skipped (Mentor's internal `$Nxxxx` net names).
Exposed as the `analyze` shell command which prints groups (sorted by module + label) followed by anomalies. Designed to be consumed by the upcoming dashboard so the summary is visible at a glance. Tests: `tests/test_analysis.cpp`.
**Component classification**: every `Part` carries a `ComponentKind kind` (`Passive | Semiconductor | IntegratedCircuit | Connector | TestPoint | Switch | Crystal | Mechanical | Other`) inferred at construction by `infer_component_kind(name)` from the leading reference-designator letter(s) (longest-match: `LED/TP/SW/FB/MK/MP/MH/HS/RA/RN/RP/RV` first, then single-letter R/C/L/F/D/Q/U/J/P/Y/X/S). Recomputed on `restore` (no persistence tag). Not yet exposed in TUI commands — branchpoints will be `search` filter, `set-type` guard, and `explore` header.
@@ -112,6 +123,8 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
**Pins** are either NC (`signal() == nullptr`) or connected to exactly one signal. The ODS importer creates a Pin for every row that has a non-empty pin name, even when the signal column is empty or `"NC"` — the pin stays in the Part as NC. `restore` replaces `Tui::sys` entirely (`unique_ptr::reset`). Names are stored as-is — must not contain TAB or newline (true for the EE netlists we ingest). Format is versioned by the `# essim system snapshot v1` header for future compatibility.
**NC origin tag**: each `Pin` carries `NcOrigin nc_origin` (`None | ImportedUnconnected | DroppedSingleton`, default `None`). Set in three places: (a) Mentor importer when the signal field starts with `unconnected``ImportedUnconnected`; (b) `drop_singleton_signals(Signals*)` called at the end of `load``DroppedSingleton` on each detached pin (signals with exactly one pin are NC by definition — see commits motivating this); (c) `duplicate` propagates the tag. Pins materialised by `FillIdentityNCs` keep `None` — they have no local signal but are bridged via `pin_map` and shouldn't be counted as orphans. The tag is persisted (see `N` record), reported as a total in `verify`, and tested in `tests/test_nc_origin.cpp`.
**Connector types & transforms**: every `Part` carries a `connector_type` string (default `""`, set via the `set-type` command — inline `set-type m p kind` or bare which opens a TUI screen with module menu, part filter+menu, type input, list of types already in use, and an Apply button). When `connect` validates a pair, it consults `TransformRegistry::lookup(p1->connector_type, p2->connector_type)` (defined in `src/system/transform.{hpp,cpp}`) — both directions of the pair are tried. If neither is registered, an `IdentityTransform` fallback wires each pin of A to the canonical-equivalent pin of B (when present). The resulting `(Pin*, Pin*)` list and the transform's name are stored on the `Connection` (`pin_map`, `transform_name`). To register a real transform: define a `Transform` subclass in `transform.cpp` and call `TransformRegistry::get().add("kindA", "kindB", new MyTransform())` at init — there's no startup hook for this yet, so a small `RegisterBuiltinTransforms()` helper is the natural place to add when more types appear.
**Identity wiring uses canonical names**: `IdentityTransform::apply` builds `unordered_map<canonical, Pin*>` for side B and looks up each side-A pin by its canonical form. So `A1` (one card) auto-pairs with `A001` (the other) thanks to `canonical_pin_name` (`pre + zero-padded(3) digit suffix`; mixed/non-numeric returns the original). Same canonicalisation in `CheckIdentityCompatible`. **`pin_role` doesn't need canonicalisation** because `parse_pin` extracts `(col, row)` via `stoi` which already strips leading zeros.
@@ -142,6 +155,8 @@ The Connection record stores `Module*`/`Part*` for both endpoints (added to `Con
- `Tab` cycles focus between the query input and the menus. **Implemented manually** in the outer `CatchEvent`: `Menu::OnEvent` consumes `Event::Tab` to cycle its own entries and returns `true`, which prevents `Container::Vertical` from ever seeing the event (Container only cycles between children when the active child returns `false`). So we short-circuit Tab/TabReverse upstream and mutate `search_focus_idx` directly.
- `Esc` exits the search mode (flips `screen_idx` back to 0). The search state (selected module/type, query) is preserved across re-entries until `search` is run again.
**Signal-type popup (shared)**: Enter on a signal entry in the `net` or `explore` screen opens a modal (`Tui::sigtype_dialog_open`) that lets the user pick `power | gnd | other` for the currently-selected signal. Built in `screen_sigtype_modal.cpp::BuildSignalTypeModal()`, attached to both screens via the `Modal(...)` decorator in `Run()`. Inside the modal, Enter applies + closes + records `set-signal-type <m> <s> <t>` in the script-save buffer (`recorded`); Esc closes without applying. Two safeguards: (a) re-selecting the type the pin already has is a no-op and records nothing; (b) the recorder collapses consecutive edits of the same `(module, signal)` — if the previous line in `recorded` already targets the same pair, it is replaced rather than appended. The outer `CatchEvent` in `Run()` cedes Tab/Esc to the modal whenever `sigtype_dialog_open` is true, so the underlying screen doesn't yank focus back. In `explore` the popup also fires from the detail pane when browsing `parts`: each `pin → signal` row carries its signal name in the parallel `explore_detail_sig` vector, and Enter on a non-`(NC)` row opens the popup for that signal.
`net` is dual-mode (`prompt_for_missing = false`, `interactive = true`):
- Inline: `net <module> <signal>` — prints the BFS-reached `(module, signal)` set in the visualisation area (with types and an `[INCONSISTENT]` flag).
- Bare: opens `screen_idx = 5`. Three columns: module `Menu` (left), filter `Input` + filtered signal `Menu` of the selected module (middle), and a read-only panel (right) that recomputes the net on every frame and lists `(module, signal, type)` for each member plus a header summarising count + dominant type + inconsistency flag. The signal list is sorted with `NaturalLess`; `net_sig_idx` is clamped if the filter shrinks it. `Tab` cycles 3 fields (filter → module → signal); `Esc` leaves.
@@ -160,6 +175,7 @@ Each successful submission appends a single line to the file (so a crash doesn't
- All three importers (`IMPORT_MENTOR`, `IMPORT_ALTIUM`, `IMPORT_ODS`) are wired in `System::Load`. Wrap calls in `try/catch` (the TUI does).
- **Altium importer drops NC pins entirely**: the source format only enumerates pins inside `(signal …)` blocks, so positions not connected to any signal on this card never become `Pin`s. Mentor (via `Explicit Pin:`) and ODS (one row per pin) materialise NC. This is the asymmetry that motivates `FillIdentityNCs` at `connect` time and (eventually) `FillPartFromLayout` at `set-type` time.
- **Mentor importer + NC**: the Mentor `.qcv` format names every pin's signal explicitly. Sentinel values like `'unconnected'` or `'unconnected (by TERM)'` mean NC — the parser detects them via `is_nc_signal_name` (lowercase prefix match) and keeps the pin on the part with no signal, tagged `ImportedUnconnected`. Additionally, after each `load` the system runs `drop_singleton_signals(mod->signals)`: any signal whose pin set has size 1 is unconnected by definition (electrically nowhere to go), so it is detached and the lone pin is tagged `DroppedSingleton`. The count is shown inline in the `load` output. The semantics covers both Mentor patterns and the few `NC_*`-prefixed signals that turn out to be singletons in real-world boards — the name `NC_*` alone is *not* enough (most of them connect two or more parts and are real bridges, even if cosmetically called NC).
- ODS importer: each spreadsheet sheet becomes a `Part` (sheet name = part name). Rows are pin/signal pairs; the **first non-empty row of each sheet is dropped as a header** (no validation of header content). Empty cells skip the row; `"NC"` keeps the pin in the part but doesn't connect it to a signal. Pins or parts whose name collides (rare in well-formed sheets) are silently dropped.
- `System::Load` throws `std::runtime_error("Unknown import type")` for any value outside the three enum cases.
- `Modules`/`Parts`/etc. have no const-correct iteration on the `*` accessor; iterators on `SystemElementContainer<T>` are available but `begin()`/`end()` are non-const-safe in some places.

171
src/system/analysis.cpp Normal file
View File

@@ -0,0 +1,171 @@
#include "analysis.hpp"
#include "modules.hpp"
#include "signals.hpp"
#include "system.hpp"
#include <cctype>
#include <cstdlib>
#include <map>
#include <unordered_map>
const char *group_kind_name(GroupKind k) {
switch (k) {
case GroupKind::DiffPair: return "diff-pair";
case GroupKind::Bus: return "bus";
}
return "?";
}
const char *anomaly_kind_name(AnomalyKind k) {
switch (k) {
case AnomalyKind::DiffPairOrphan: return "diff-pair-orphan";
case AnomalyKind::BusGap: return "bus-gap";
}
return "?";
}
namespace {
// Diff-pair suffix detection. Returns true and fills <stem, polarity> if
// `name` ends with one of {_P, _N, _p, _n} preceded by a non-suffix char.
// 'P' / 'N' result is normalised to uppercase.
bool diff_suffix(const std::string &name, std::string &stem, char &pol) {
if (name.size() < 3) return false;
char last = name.back();
char up = (char)std::toupper((unsigned char)last);
if (up != 'P' && up != 'N') return false;
char sep = name[name.size() - 2];
if (sep != '_') return false;
stem = name.substr(0, name.size() - 2);
pol = up;
return true;
}
// Bus suffix detection. Two accepted forms:
// - bracketed: NAME[12] → stem "NAME", idx 12
// - underscore: NAME_12 → stem "NAME_", idx 12 (underscore is REQUIRED
// before the digits so we don't misread
// names like "GETH_01_VDD12" as a bus)
// Returns false otherwise.
bool numeric_suffix(const std::string &name, std::string &stem, int &idx,
bool &bracketed) {
if (name.empty()) return false;
bracketed = false;
if (name.back() == ']') {
size_t open = name.rfind('[');
if (open == std::string::npos || open == 0) return false;
for (size_t i = open + 1; i < name.size() - 1; ++i)
if (!std::isdigit((unsigned char)name[i])) return false;
idx = std::atoi(name.c_str() + open + 1);
stem = name.substr(0, open);
bracketed = true;
return !stem.empty();
}
size_t i = name.size();
while (i > 0 && std::isdigit((unsigned char)name[i - 1])) --i;
if (i == name.size() || i < 2) return false; // no digits, or no room for stem+_
if (name[i - 1] != '_') return false; // strict `_` before digits
idx = std::atoi(name.c_str() + i);
stem = name.substr(0, i); // includes the trailing '_'
return true;
}
// Tool-internal net names we never want to surface to the user (Mentor's
// `$Nxxxx` convention, ODS placeholders, etc.). Cheap prefix check.
bool is_internal_name(const std::string &n) {
return !n.empty() && n[0] == '$';
}
void analyse_module(Module *mod, AnalysisReport &out) {
// ---- Pass 1: diff pairs ----
std::unordered_map<std::string, std::pair<Signal *, Signal *>> dp; // stem -> {P, N}
for (auto &kv : *mod->signals) {
if (is_internal_name(kv.first)) continue;
std::string stem; char pol;
if (!diff_suffix(kv.first, stem, pol)) continue;
auto &slot = dp[stem];
if (pol == 'P') slot.first = kv.second;
else slot.second = kv.second;
}
for (const auto &kv : dp) {
const auto &slot = kv.second;
if (slot.first && slot.second) {
SignalGroup g;
g.kind = GroupKind::DiffPair;
g.label = kv.first + "_P/N";
g.module = mod;
g.members = {slot.first, slot.second};
out.groups.push_back(std::move(g));
continue;
}
// Orphan reporting is asymmetric on purpose: a `_P` without a `_N`
// is almost always a broken diff pair, whereas `_N` is also widely
// used as the active-low marker (RESET_N, BOOTMODE_N, …) so a `_N`
// without `_P` is too noisy to surface.
if (!slot.first) continue;
Signal *present = slot.first;
Anomaly a;
a.kind = AnomalyKind::DiffPairOrphan;
a.module = mod;
a.message = mod->name + ": " + present->name
+ " has no matching " + kv.first + "_N";
a.involved.push_back(present);
out.anomalies.push_back(std::move(a));
}
// ---- Pass 2: buses ----
// Group by stem; only consider stems with at least 2 entries.
// Mixed bracketed / non-bracketed in the same stem are treated as one
// family — the suffix value is what matters.
std::map<std::string, std::map<int, Signal *>> buses;
for (auto &kv : *mod->signals) {
if (is_internal_name(kv.first)) continue;
std::string stem; int idx; bool bracketed;
if (!numeric_suffix(kv.first, stem, idx, bracketed)) continue;
buses[stem][idx] = kv.second;
}
for (auto &bkv : buses) {
auto &members = bkv.second;
if (members.size() < 2) continue;
int lo = members.begin()->first;
int hi = members.rbegin()->first;
SignalGroup g;
g.kind = GroupKind::Bus;
g.module = mod;
g.lo = lo; g.hi = hi;
g.label = bkv.first + "[" + std::to_string(lo) + ".."
+ std::to_string(hi) + "]";
for (auto &mkv : members) g.members.push_back(mkv.second);
out.groups.push_back(std::move(g));
// Gap detection: missing indices inside [lo..hi].
std::vector<int> missing;
for (int i = lo + 1; i < hi; ++i)
if (!members.count(i)) missing.push_back(i);
if (!missing.empty()) {
Anomaly a;
a.kind = AnomalyKind::BusGap;
a.module = mod;
std::string m = mod->name + ": " + bkv.first + "["
+ std::to_string(lo) + ".." + std::to_string(hi)
+ "] missing index(es)";
for (int idx : missing) m += " " + std::to_string(idx);
a.message = std::move(m);
for (auto &mkv : members) a.involved.push_back(mkv.second);
out.anomalies.push_back(std::move(a));
}
}
}
} // namespace
AnalysisReport analyze_system(const System *sys) {
AnalysisReport out;
if (!sys) return out;
// const_cast: SystemElementContainer iteration is non-const; analysis is
// logically read-only. Mirrors the rest of the codebase.
auto *mods = const_cast<System *>(sys)->modules();
for (auto &kv : *mods) analyse_module(kv.second, out);
return out;
}

50
src/system/analysis.hpp Normal file
View File

@@ -0,0 +1,50 @@
#ifndef _ANALYSIS_HPP_
#define _ANALYSIS_HPP_
#include <string>
#include <vector>
class Module;
class Signal;
class System;
// Stateless post-processing over a System. Detects structural groups of
// signals (diff pairs, buses, …) and structural anomalies (orphan diff
// pair, gap in a bus, …). Pure read; no mutation. Re-runnable.
enum class GroupKind {
DiffPair, ///< Two signals of the form X_P / X_N (or X_p / X_n).
Bus, ///< NAME[0..N] or NAME_0..NAME_N (consecutive integer suffix).
};
struct SignalGroup {
GroupKind kind;
std::string label; ///< Human-readable, e.g. "JTAG_TDI_P/N" or "DATA[0..7]".
Module *module = nullptr; ///< Owning module (signals are module-scoped).
std::vector<Signal *> members; ///< Pointers into the module's Signals.
int lo = 0, hi = 0; ///< Bus only: extremities found.
};
enum class AnomalyKind {
DiffPairOrphan, ///< X_P present without X_N (or vice versa).
BusGap, ///< NAME[0..N] has a missing index inside the range.
};
struct Anomaly {
AnomalyKind kind;
std::string message;
Module *module = nullptr;
std::vector<Signal *> involved;
};
struct AnalysisReport {
std::vector<SignalGroup> groups;
std::vector<Anomaly> anomalies;
};
AnalysisReport analyze_system(const System *sys);
const char *group_kind_name(GroupKind k);
const char *anomaly_kind_name(AnomalyKind k);
#endif // _ANALYSIS_HPP_

View File

@@ -1,6 +1,7 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/analysis.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/nets.hpp"
@@ -280,6 +281,56 @@ void Tui::RegisterCommands() {
}, true,
"check pin roles locally and signal-type consistency across bridged nets" };
commands["analyze"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
AnalysisReport rep = analyze_system(sys.get());
int n_diff = 0, n_bus = 0;
for (const auto &g : rep.groups) {
if (g.kind == GroupKind::DiffPair) ++n_diff;
else if (g.kind == GroupKind::Bus) ++n_bus;
}
int n_dp_orph = 0, n_bus_gap = 0;
for (const auto &a : rep.anomalies) {
if (a.kind == AnomalyKind::DiffPairOrphan) ++n_dp_orph;
else if (a.kind == AnomalyKind::BusGap) ++n_bus_gap;
}
Print("analyze: " + std::to_string(n_diff) + " diff pair(s), "
+ std::to_string(n_bus) + " bus(es).");
// Sort groups by module then label so output is stable.
auto by_label = [](const SignalGroup &a, const SignalGroup &b) {
std::string ma = a.module ? a.module->name : std::string{};
std::string mb = b.module ? b.module->name : std::string{};
if (ma != mb) return ma < mb;
return a.label < b.label;
};
auto groups = rep.groups; // copy: report stays untouched
std::sort(groups.begin(), groups.end(), by_label);
for (const auto &g : groups) {
std::string mname = g.module ? g.module->name : std::string("?");
std::string line = " " + mname + "/" + g.label
+ " [" + group_kind_name(g.kind) + "]"
+ "" + std::to_string(g.members.size())
+ " signal(s)";
Print(line);
}
if (rep.anomalies.empty()) {
Print("analyze: no anomaly.");
} else {
Print("analyze: " + std::to_string(rep.anomalies.size())
+ " anomaly(ies) ("
+ std::to_string(n_dp_orph) + " diff-pair orphan, "
+ std::to_string(n_bus_gap) + " bus gap):");
for (const auto &a : rep.anomalies)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] "
+ a.message);
}
}, true,
"detect signal groups (diff pairs, buses) and structural anomalies" };
commands["net"] = {
{{"module", Completion::None},
{"signal name", Completion::None}},

182
tests/test_analysis.cpp Normal file
View File

@@ -0,0 +1,182 @@
#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 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("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)
}

125
tests/test_nc_origin.cpp Normal file
View File

@@ -0,0 +1,125 @@
#include <doctest/doctest.h>
#include "system/modules.hpp"
#include "system/parts.hpp"
#include "system/persist.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <filesystem>
#include <memory>
#include <string>
TEST_CASE("nc_origin_tag round-trips with from_tag for tagged variants") {
NcOrigin o;
REQUIRE(nc_origin_from_tag("U", o));
CHECK(o == NcOrigin::ImportedUnconnected);
REQUIRE(nc_origin_from_tag("D", o));
CHECK(o == NcOrigin::DroppedSingleton);
CHECK(std::string(nc_origin_tag(NcOrigin::ImportedUnconnected)) == "U");
CHECK(std::string(nc_origin_tag(NcOrigin::DroppedSingleton)) == "D");
CHECK(std::string(nc_origin_tag(NcOrigin::None)) == "");
}
TEST_CASE("nc_origin_from_tag rejects unknown / empty tags") {
NcOrigin o = NcOrigin::None;
CHECK(!nc_origin_from_tag("", o));
CHECK(!nc_origin_from_tag("X", o));
CHECK(!nc_origin_from_tag("Unknown", o));
}
TEST_CASE("drop_singleton_signals detaches size-1 signals and tags pins") {
Module mod("M");
Part *prt = new Part("U1");
mod.add(prt);
auto add_pin = [&](const std::string &pin_name, const std::string &sig) {
Pin *p = new Pin(pin_name);
prt->add(p);
Signal *s = mod.signals->merge(sig);
s->add(p);
p->connect(s);
return p;
};
// Two-pin signal: should survive.
Pin *pa = add_pin("A1", "BUS_X");
Pin *pb = add_pin("A2", "BUS_X");
// Singleton: should be dropped, pin tagged DroppedSingleton.
Pin *po = add_pin("A3", "ORPHAN");
REQUIRE(mod.signals->size() == 2);
int dropped = drop_singleton_signals(mod.signals);
CHECK(dropped == 1);
CHECK(mod.signals->size() == 1); // BUS_X kept
CHECK(mod.signals->exists("BUS_X"));
CHECK(!mod.signals->exists("ORPHAN"));
CHECK(pa->signal() != nullptr); // unchanged
CHECK(pb->signal() != nullptr);
CHECK(po->signal() == nullptr); // detached
CHECK(po->nc_origin == NcOrigin::DroppedSingleton);
CHECK(pa->nc_origin == NcOrigin::None);
}
TEST_CASE("drop_singleton_signals is a no-op on a clean module") {
Module mod("M");
Part *prt = new Part("U1");
mod.add(prt);
auto *p1 = new Pin("A1"); prt->add(p1);
auto *p2 = new Pin("A2"); prt->add(p2);
Signal *s = mod.signals->merge("MULTIPIN");
s->add(p1); p1->connect(s);
s->add(p2); p2->connect(s);
CHECK(drop_singleton_signals(mod.signals) == 0);
CHECK(mod.signals->size() == 1);
}
TEST_CASE("persist round-trip preserves nc_origin tags") {
auto sys = std::make_unique<System>();
Module *mod = sys->modules()->merge("M");
Part *prt = new Part("U1");
mod->add(prt);
auto *connected = new Pin("A1");
prt->add(connected);
Signal *s = mod->signals->merge("SIG");
s->add(connected); connected->connect(s);
auto *imported_nc = new Pin("A2");
imported_nc->nc_origin = NcOrigin::ImportedUnconnected;
prt->add(imported_nc);
auto *dropped = new Pin("A3");
dropped->nc_origin = NcOrigin::DroppedSingleton;
prt->add(dropped);
// A pin with no signal and no origin (e.g. an old snapshot or a
// FillIdentityNCs-style materialisation): tag stays None on restore.
auto *bare_nc = new Pin("A4");
prt->add(bare_nc);
std::string path =
(std::filesystem::temp_directory_path() / "essim_nc_origin.txt").string();
std::string err;
REQUIRE(save_system(sys.get(), path, err));
std::unique_ptr<System> restored(restore_system(path, err));
REQUIRE(restored);
std::filesystem::remove(path);
Part *rp = restored->modules()->get("M")->get("U1");
CHECK(rp->get("A1")->nc_origin == NcOrigin::None);
CHECK(rp->get("A2")->nc_origin == NcOrigin::ImportedUnconnected);
CHECK(rp->get("A3")->nc_origin == NcOrigin::DroppedSingleton);
CHECK(rp->get("A4")->nc_origin == NcOrigin::None);
}
TEST_CASE("next_signal_type cycles Power → Gnd → Other → Power") {
CHECK(next_signal_type(SignalType::Power) == SignalType::GndShield);
CHECK(next_signal_type(SignalType::GndShield) == SignalType::Other);
CHECK(next_signal_type(SignalType::Other) == SignalType::Power);
}