diff --git a/DESIGN.md b/DESIGN.md index a044106..7306226 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 ` 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 ` 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 ` 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 `). 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 ` 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 `. 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 ` 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 ` 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` 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 ` 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 ` — 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` are available but `begin()`/`end()` are non-const-safe in some places. diff --git a/src/system/analysis.cpp b/src/system/analysis.cpp new file mode 100644 index 0000000..e0d6c96 --- /dev/null +++ b/src/system/analysis.cpp @@ -0,0 +1,171 @@ +#include "analysis.hpp" + +#include "modules.hpp" +#include "signals.hpp" +#include "system.hpp" + +#include +#include +#include +#include + +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 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> 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> 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 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(sys)->modules(); + for (auto &kv : *mods) analyse_module(kv.second, out); + return out; +} diff --git a/src/system/analysis.hpp b/src/system/analysis.hpp new file mode 100644 index 0000000..36bee04 --- /dev/null +++ b/src/system/analysis.hpp @@ -0,0 +1,50 @@ +#ifndef _ANALYSIS_HPP_ +#define _ANALYSIS_HPP_ + +#include +#include + +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 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 involved; +}; + +struct AnalysisReport { + std::vector groups; + std::vector anomalies; +}; + +AnalysisReport analyze_system(const System *sys); + +const char *group_kind_name(GroupKind k); +const char *anomaly_kind_name(AnomalyKind k); + +#endif // _ANALYSIS_HPP_ diff --git a/src/tui/commands.cpp b/src/tui/commands.cpp index 523f4bc..19f59bc 100644 --- a/src/tui/commands.cpp +++ b/src/tui/commands.cpp @@ -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}}, diff --git a/tests/test_analysis.cpp b/tests/test_analysis.cpp new file mode 100644 index 0000000..d0e750f --- /dev/null +++ b/tests/test_analysis.cpp @@ -0,0 +1,182 @@ +#include + +#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 +#include + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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) +} diff --git a/tests/test_nc_origin.cpp b/tests/test_nc_origin.cpp new file mode 100644 index 0000000..cb769c0 --- /dev/null +++ b/tests/test_nc_origin.cpp @@ -0,0 +1,125 @@ +#include + +#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 +#include +#include + +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(); + 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 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); +}