From 0949452745c772d0caac88297321432562e25c14 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Sat, 11 Apr 2026 11:00:55 -0400 Subject: [PATCH] Channel Focus --- .coverage | Bin 69632 -> 69632 bytes docs/QA-2026-04-11_1054.md | 210 ++++++++++++++++++ src/impakt/web/assets/channel_nav.js | 68 ++++++ src/impakt/web/assets/style.css | 16 ++ src/impakt/web/callbacks/channel_callbacks.py | 59 ++++- src/impakt/web/callbacks/plot_callbacks.py | 44 +++- src/impakt/web/layout.py | 1 + 7 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 docs/QA-2026-04-11_1054.md create mode 100644 src/impakt/web/assets/channel_nav.js diff --git a/.coverage b/.coverage index aff8561df8cdcd3396d2c6342ef1ab626dc5a1e9..ec973e7cfc166671176db856ac3f2d9281833326 100644 GIT binary patch delta 1855 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 diff --git a/docs/QA-2026-04-11_1054.md b/docs/QA-2026-04-11_1054.md new file mode 100644 index 0000000..9d7b0a8 --- /dev/null +++ b/docs/QA-2026-04-11_1054.md @@ -0,0 +1,210 @@ +# Quality Assessment -- 2026-04-11 + +> **Score: 83.7 / 100 — Grade: B+** +> Strong jump of +5.4 points from B (78.3) to B+ (83.7) driven by lint reaching perfection (0 violations) and architecture completing its public-API surface (all 11 modules now export `__all__`); type safety improved incrementally while complexity ticked slightly down as two files crossed the 300-line threshold. + +| Dimension | Score | +|-----------|-------| +| Test Health | 8.0/10 | +| Type Safety | 6.8/10 | +| Lint Hygiene | 10.0/10 | +| Architecture | 10.0/10 | +| Documentation | 9.0/10 | +| Complexity | 6.5/10 | +| Security | 8.5/10 | +| Maintainability | 8.5/10 | + +**Version:** 0.1.0 +**Assessed by:** Claude Sonnet 4.6 +**Previous assessment:** QA-2026-04-11_0619.md + +--- + +## Inventory + +| Metric | Value | +|--------|-------| +| Source files | 72 | +| Source lines | 10,563 | +| Test files | 30 | +| Test lines | 2,736 | +| Test:source ratio | 0.259 | +| Direct dependencies | 10 core + 1 optional + 4 dev | + +--- + +## Raw Metrics + +### Test Suite + +``` +TOTAL 3746 1142 70% + +20 files skipped due to complete coverage. +Required test coverage of 60% reached. Total coverage: 69.51% +240 passed, 7 warnings in 16.64s +``` + +- Tests collected: 240 +- Tests passed: 240 +- Tests failed: 0 +- Test duration: 16.64s +- Coverage: 69.51% (pytest-cov now configured; 60% floor enforced) + +### Type Safety (mypy --strict) + +``` +Found 34 errors in 16 files (checked 72 source files) +``` + +- Total errors: 34 +- Files with errors: 16 / 72 (78% clean) +- Top error categories: + - `[attr-defined]` 9 — attribute access on loosely typed objects + - `[no-any-return]` 6 — returning `Any` from typed functions + - `[return-value]` 4 — incompatible return type + - `[import-untyped]` 3 — third-party stubs missing + - `[assignment]` 3 — incompatible assignment + - `[var-annotated]` 2 — need type annotation + - `[valid-type]` 2 — invalid type expression + - `[no-untyped-call]` 2 — call to untyped function + - `[unused-ignore]` 1 + - `[no-untyped-def]` 1 + - `[comparison-overlap]` 1 + +### Lint (ruff) + +``` +All checks passed! +``` + +- Total violations: 0 +- Auto-fixable: N/A +- Top violation rules: none + +### Complexity + +- File size: min=1 / median=132 / mean=147 / max=692 +- Files >300 lines: 8 / 72 +- High-complexity files (branch density >15): + +``` + 80 src/impakt/io/mme.py (692 lines) -- ISO 13499 parser, justified + 44 src/impakt/web/components/criteria.py (342 lines) -- UI assembly with protocol logic + 30 src/impakt/web/callbacks/plot_callbacks.py (324 lines) -- transform pipeline orchestration + 30 src/impakt/channel/model.py (456 lines) -- core data model, multiple classes + 27 src/impakt/web/state.py (272 lines) -- app state with multi-test support + 27 src/impakt/protocol/euro_ncap.py (237 lines) -- sliding-scale scoring tables + 23 src/impakt/plot/engine.py (303 lines) -- Plotly rendering with corridors + 21 src/impakt/web/callbacks/channel_callbacks.py (236 lines) -- selection/filter callbacks + 21 src/impakt/protocol/iihs.py (179 lines) -- G/A/M/P rating logic + 20 src/impakt/web/components/channel_grid.py (418 lines) -- DataTable assembly + 19 src/impakt/script/cli.py (137 lines) -- CLI arg parsing +``` + +### Documentation + +- Docstring coverage: 420 / 457 definitions (91.9%) +- Modules with `__all__`: 11 / 11 (all modules) + - channel: YES + - criteria: YES + - io: YES *(new)* + - plot: YES + - plugin: YES *(new)* + - protocol: YES + - report: YES *(new)* + - script: YES *(new)* + - template: YES *(new)* + - transform: YES + - web: YES +- README: 1,266 lines with Mermaid diagrams +- Architectural diagrams: yes + +### Security + +- eval/exec (sandboxed): 1 — `math_expr.py`, restricted builtins `{}` + 16-token blocklist; excluded from grep via `# noqa: S307` +- eval/exec (unsandboxed): 0 +- subprocess: 1 confirmed false positive — `src/impakt/transform/math_expr.py:68: "subprocess",` is a forbidden-token blocklist string, not an invocation +- Hardcoded secrets: 0 +- Bare except: 0 + +### Maintainability + +- TODO: 0 +- FIXME: 0 +- HACK: 0 +- Logging calls: 50 +- try/except blocks: 53 +- Bare excepts: 0 +- Internal imports (coupling): 198 + +--- + +## Scorecard + +| # | Dimension | Weight | Score | Weighted | Justification | +|---|-----------|--------|-------|----------|---------------| +| 1 | Test Health | 20% | 8.0/10 | 1.60 | 240/240 pass; test:source ratio 0.259 (within 0.2–0.5 band); integration tests with real datasets present; 69.51% coverage now measured (below 80% ceiling for a 10). | +| 2 | Type Safety | 15% | 6.8/10 | 1.02 | mypy strict, 34 errors in 16 files; all 17 `[type-arg]` errors resolved since last run. Linear interpolation between 6 (<50) and 8 (<10): 6 + (50-34)/(50-10) × 2 = 6.8. | +| 3 | Lint Hygiene | 10% | 10.0/10 | 1.00 | `ruff check src/` reports 0 violations. Perfect score; all 89 prior violations cleared. | +| 4 | Architecture | 15% | 10.0/10 | 1.50 | Clear 4-layer design (io → transform/channel → protocol → web/plot). Plugin system present. All 11 public modules export `__all__`. No layer violations confirmed (io/transform/protocol/channel do not import from web or plot). | +| 5 | Documentation | 10% | 9.0/10 | 0.90 | 91.9% docstring coverage exceeds 90% threshold. Comprehensive README with Mermaid diagrams. No generated API reference (Sphinx/mkdocs), so not a full 10. | +| 6 | Complexity | 10% | 6.5/10 | 0.65 | Median 132 (<150, excellent). 8 files >300 lines (up from 6). `plot_callbacks.py` (324) and `engine.py` (303) newly crossed threshold. Interpolated between 8 (≤3 files >300) and 6 (≤10): 6 + (10-8)/(10-3) × 2 = 6.57 → 6.5. | +| 7 | Security | 10% | 8.5/10 | 0.85 | Single eval sandboxed with `{"__builtins__": {}}` + 16-item token blocklist. Subprocess hit confirmed false positive (blocklist string, not call). No secrets, no bare excepts. Interpolated between 9 (fully sandboxed) and 7 (partially sandboxed). | +| 8 | Maintainability | 10% | 8.5/10 | 0.85 | Zero debt markers. Zero bare excepts. 50 logging calls. Modern tooling (uv, hatchling, ruff, mypy). Between 10 (perfect) and 8 (<5 markers). | + +### Composite Score: **83.7 / 100** +### Grade: **B+** + +Calculation: (8.0×0.20 + 6.8×0.15 + 10.0×0.10 + 10.0×0.15 + 9.0×0.10 + 6.5×0.10 + 8.5×0.10 + 8.5×0.10) × 10 += (1.60 + 1.02 + 1.00 + 1.50 + 0.90 + 0.65 + 0.85 + 0.85) × 10 += 8.37 × 10 = **83.7** + +--- + +## Delta from Previous Assessment + +| Dimension | Previous | Current | Change | +|-----------|----------|---------|--------| +| Test Health | 8.0 | 8.0 | 0.0 | +| Type Safety | 6.5 | 6.8 | **+0.3** | +| Lint Hygiene | 6.0 | 10.0 | **+4.0** ↑ | +| Architecture | 9.0 | 10.0 | **+1.0** ↑ | +| Documentation | 9.0 | 9.0 | 0.0 | +| Complexity | 7.0 | 6.5 | **-0.5** ↓ | +| Security | 8.5 | 8.5 | 0.0 | +| Maintainability | 8.5 | 8.5 | 0.0 | +| **Composite** | **78.3** | **83.7** | **+5.4** ↑ | + +--- + +## Top Improvements Since Last Assessment + +1. **Lint Hygiene: 6.0 → 10.0** — All 89 ruff violations resolved (73 auto-fixed + 16 manual, including 8 `F601` duplicate dict keys). First perfect lint score. +2. **Architecture: 9.0 → 10.0** — Five modules (`io`, `plugin`, `report`, `script`, `template`) received proper `__all__` exports, completing the public-API surface across all 11 packages. +3. **Type Safety: 6.5 → 6.8** — 15 fewer mypy errors (49 → 34); all 17 `[type-arg]` errors from generic parameters resolved; 4 fewer files with errors (20 → 16). + +--- + +## Recommended Actions (Priority Order) + +| # | Action | Effort | Impact | Dimensions Affected | +|---|--------|--------|--------|---------------------| +| 1 | Increase test coverage from 69.51% to ≥80%: add unit tests for uncovered branches in `web/state.py`, `plot/engine.py`, and `io/mme.py` parser edge-cases | 2–4 hr | Test Health +1.0 → 9.0 (+0.20 composite) | Test Health | +| 2 | Resolve 9 `[attr-defined]` + 6 `[no-any-return]` + 4 `[return-value]` mypy errors in the web layer to reach <10 total errors | 1–2 hr | Type Safety +1.2 → 8.0 (+0.18 composite) | Type Safety | +| 3 | Decompose `channel_grid.py` (418 lines) and `channel/model.py` (456 lines) into focused sub-modules; extract sub-parsers from `mme.py` if it grows beyond 800 lines | 3–5 hr | Complexity +0.5 → 7.0 (+0.05 composite) | Complexity | +| 4 | Set up mkdocs-material + mkdocstrings to auto-generate API reference from existing docstrings | 1–2 hr | Documentation +1.0 → 10.0 (+0.10 composite) | Documentation | +| 5 | Replace `eval`-based math expression evaluator in `math_expr.py` with an AST-based parser (e.g., `ast.parse` + safe node visitor) to eliminate last eval usage | 2–3 hr | Security +0.5 → 9.0 (+0.05 composite) | Security | + +**Projected composite after actions 1–4: ~87 (B+), after all 5: ~88 (B+)** + +--- + +## Notes + +- **Architecture qualitatively verified.** All five newly-added `__init__.py` files were inspected and follow the pattern of explicit imports + `__all__` lists. No layer violations detected: `io`, `transform`, `protocol`, and `channel` packages contain no imports from `web` or `plot`. +- **pytest-cov is now active.** The test run enforces a 60% coverage floor (`--cov --cov-report=term-missing` or equivalent in `pyproject.toml`). The overall 69.51% exceeds the floor but is below the 80% threshold required to push Test Health to 9.0. Note: the `--co` (collect-only) invocation showed only 30.81% because 11 files are skipped during collection; the full run gives 69.51%. +- **Complexity slight regression.** Two files crossed the 300-line threshold since the last run: `plot_callbacks.py` (249 → 324 lines) and `plot/engine.py` (257 → 303 lines). Both contain justified density (pipeline orchestration and Plotly rendering respectively), but the increase in count from 6 to 8 files >300 reduces the score from 7.0 to 6.5 per the rubric interpolation. +- **Security subprocess false positive** persists: `math_expr.py:68` contains the string `"subprocess"` as an entry in its forbidden-token blocklist. No actual subprocess invocation exists in the codebase. +- **Type-arg errors fully resolved.** The previous leading error category (`[type-arg]` = 17) is now zero. Remaining errors are concentrated in `[attr-defined]` (9) and `[no-any-return]` (6), both in the web callback layer where Dash's typing stubs are incomplete. +- **Source line growth** (+238 lines, 10,325 → 10,563) is consistent with the five new `__init__.py` additions and growth in existing files. diff --git a/src/impakt/web/assets/channel_nav.js b/src/impakt/web/assets/channel_nav.js new file mode 100644 index 0000000..b762dd8 --- /dev/null +++ b/src/impakt/web/assets/channel_nav.js @@ -0,0 +1,68 @@ +/** + * Channel grid keyboard navigation. + * + * PgDn / ArrowDown: move focus to the next row in the channel grid. + * PgUp / ArrowUp: move focus to the previous row. + * + * Simulates a click on the target row's first cell, which triggers + * Dash's active_cell callback and updates the focus channel. + */ +(function () { + "use strict"; + + document.addEventListener("keydown", function (e) { + if (e.key !== "PageDown" && e.key !== "PageUp" && + e.key !== "ArrowDown" && e.key !== "ArrowUp") { + return; + } + + // Don't intercept if user is typing in an input field + var tag = (e.target.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") { + return; + } + + var table = document.getElementById("channel-grid"); + if (!table) return; + + var rows = table.querySelectorAll("tbody tr"); + if (rows.length === 0) return; + + // Find the currently active (focused) row + var activeRow = table.querySelector("tbody tr.focused, tbody tr td.focused"); + var currentIdx = -1; + if (activeRow) { + var row = activeRow.closest("tr"); + currentIdx = Array.prototype.indexOf.call(rows, row); + } + + // Also check for active cell via Dash's cell-active class + if (currentIdx < 0) { + var activeCell = table.querySelector("td.cell--active, td.dash-cell--active, td.focused"); + if (activeCell) { + var parentRow = activeCell.closest("tr"); + currentIdx = Array.prototype.indexOf.call(rows, parentRow); + } + } + + var direction = (e.key === "PageDown" || e.key === "ArrowDown") ? 1 : -1; + var nextIdx = currentIdx + direction; + + // Clamp to valid range + if (nextIdx < 0) nextIdx = 0; + if (nextIdx >= rows.length) nextIdx = rows.length - 1; + + // Click the first data cell of the target row to trigger active_cell + var targetRow = rows[nextIdx]; + if (targetRow) { + var cell = targetRow.querySelector("td"); + if (cell) { + cell.click(); + // Scroll the row into view + targetRow.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + + e.preventDefault(); + }); +})(); diff --git a/src/impakt/web/assets/style.css b/src/impakt/web/assets/style.css index 2ab37b0..690383a 100644 --- a/src/impakt/web/assets/style.css +++ b/src/impakt/web/assets/style.css @@ -62,6 +62,22 @@ resize: none; } +/* Channel grid: clicking a row selects the whole row, not just a cell */ +#channel-grid td.cell--active { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +/* Remove the blue cell focus ring from DataTable */ +#channel-grid .dash-cell.focused { + outline: none !important; +} + +#channel-grid td.cell--selected { + background-color: inherit !important; +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; diff --git a/src/impakt/web/callbacks/channel_callbacks.py b/src/impakt/web/callbacks/channel_callbacks.py index f8ac29f..7588e35 100644 --- a/src/impakt/web/callbacks/channel_callbacks.py +++ b/src/impakt/web/callbacks/channel_callbacks.py @@ -6,6 +6,8 @@ Handles: indices to preserve selection across filter changes - Selected channels badge display with consistent colors - Per-channel CFC override controls +- Focus channel: clicking a row sets it as the focus (plotted on top) +- PgUp/PgDn keyboard navigation through the channel grid """ from __future__ import annotations @@ -13,7 +15,7 @@ from __future__ import annotations from typing import Any import dash -from dash import ALL, Input, Output, State +from dash import ALL, Input, Output, State, no_update from impakt.plot.engine import DEFAULT_COLORS from impakt.web.components.channel_grid import ( @@ -23,6 +25,9 @@ from impakt.web.components.channel_grid import ( from impakt.web.components.transforms import build_per_channel_override_rows from impakt.web.state import AppState +# Focus highlight color +_FOCUS_COLOR = "#ffc107" # amber/gold + def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: """Register all channel selection and filtering callbacks.""" @@ -78,6 +83,25 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: def update_badges(selected_keys: list[str] | None) -> list[Any]: return build_selected_channels_badges(selected_keys or [], app_state) + # --- Focus channel: clicking a row sets it as the plot focus --- + + @app.callback( + Output("focus-channel-store", "data"), + [Input("channel-grid", "active_cell")], + [State("channel-grid", "data")], + ) + def update_focus_channel( + active_cell: dict[str, Any] | None, + visible_data: list[dict[str, Any]] | None, + ) -> str | None: + """Set focus channel when user clicks a row.""" + if active_cell is None or not visible_data: + return no_update + row_idx = active_cell.get("row", -1) + if 0 <= row_idx < len(visible_data): + return visible_data[row_idx]["key"] + return no_update + @app.callback( [ Output("channel-grid", "data"), @@ -91,6 +115,7 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: Input("facet-meas", "value"), Input("facet-direction", "value"), Input("selected-channels-store", "data"), + Input("focus-channel-store", "data"), ], [State("channel-grid-all-rows", "data")], ) @@ -101,6 +126,7 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: meas: str | None, direction: str | None, selected_keys: list[str] | None, + focus_key: str | None, all_rows: list[dict[str, Any]] | None, ) -> tuple[list[dict[str, Any]], list[int], list[dict[str, Any]]]: """Filter rows AND recompute selected_rows + color styling.""" @@ -134,17 +160,34 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: # Build style_data_conditional for coloring selected rows style_cond: list[dict[str, Any]] = [] + + # Focus row highlight (full row, amber background) + for idx, row in enumerate(filtered): + if focus_key and row["key"] == focus_key: + style_cond.append( + { + "if": {"row_index": idx}, + "backgroundColor": f"{_FOCUS_COLOR}30", + "borderLeft": f"3px solid {_FOCUS_COLOR}", + "fontWeight": "600", + } + ) + + # Pinned (checked) row colors — applied after focus so they layer for idx, row in enumerate(filtered): if row["key"] in color_map: ci = color_map[row["key"]] color = DEFAULT_COLORS[ci % len(DEFAULT_COLORS)] - style_cond.append( - { - "if": {"row_index": idx}, - "backgroundColor": f"{color}18", # Very light tint - "borderLeft": f"3px solid {color}", - } - ) + entry: dict[str, Any] = { + "if": {"row_index": idx}, + "backgroundColor": f"{color}18", + "borderLeft": f"3px solid {color}", + } + # If this row is also the focus, use a stronger tint + if focus_key and row["key"] == focus_key: + entry["backgroundColor"] = f"{color}40" + entry["fontWeight"] = "600" + style_cond.append(entry) return filtered, selected_indices, style_cond diff --git a/src/impakt/web/callbacks/plot_callbacks.py b/src/impakt/web/callbacks/plot_callbacks.py index 2db10ad..a2a95f6 100644 --- a/src/impakt/web/callbacks/plot_callbacks.py +++ b/src/impakt/web/callbacks/plot_callbacks.py @@ -160,14 +160,19 @@ def _build_plot_spec( cursor_x1: float | None, cursor_x2: float | None, corridors: list[dict[str, Any]] | None = None, + focus_key: str | None = None, + focus_channel: tuple[str, Channel] | None = None, ) -> PlotSpec: """Build a PlotSpec from resolved channels and UI state. Channels are already transformed — they are wrapped in ChannelRef objects with no additional transform chain (transforms were applied during resolution). + + If ``focus_channel`` is provided, it is rendered last (on top) with + a thicker line so it stands out from pinned channels. """ - # Build ChannelRef objects + # Build ChannelRef objects — pinned channels first (thinner) refs: list[ChannelRef] = [] for i, (label, ch) in enumerate(channels): color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] @@ -178,6 +183,20 @@ def _build_plot_spec( ) ) + # Add focus channel last so it renders on top + if focus_channel is not None: + f_label, f_ch = focus_channel + refs.append( + ChannelRef( + channel=f_ch, + style=PlotStyle( + label=f_label, + color="#ffc107", # amber — matches focus highlight in grid + line_width=2.5, + ), + ) + ) + # Build Corridor objects from raw dicts corridor_objs: list[Corridor] = [] if corridors: @@ -219,6 +238,7 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: Output({"type": "plot-graph", "index": 0}, "figure"), [ Input("selected-channels-store", "data"), + Input("focus-channel-store", "data"), Input("cfc-select", "value"), Input("show-resultant", "value"), Input("y-align-check", "value"), @@ -234,6 +254,7 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: ) def update_main_plot( selected_keys: list[str] | None, + focus_key: str | None, cfc_value: str, show_resultant: bool, y_align: bool, @@ -259,7 +280,26 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: show_resultant, ) - spec = _build_plot_spec(channels, cursor_x1, cursor_x2, corridors_data) + # Resolve focus channel (if not already pinned) + focus_channel: tuple[str, Channel] | None = None + if focus_key and focus_key not in (selected_keys or []): + focus_resolved = _resolve_channels( + [focus_key], + app_state, + cfc_value, + y_align, + x_align_method or "none", + x_align_value, + False, # no resultant for single focus + ) + if focus_resolved: + focus_channel = focus_resolved[0] + + spec = _build_plot_spec( + channels, cursor_x1, cursor_x2, corridors_data, + focus_key=focus_key, + focus_channel=focus_channel, + ) fig = _engine.render(spec, resample=True) # Store the resampler for relayoutData callbacks diff --git a/src/impakt/web/layout.py b/src/impakt/web/layout.py index b686495..bc659b0 100644 --- a/src/impakt/web/layout.py +++ b/src/impakt/web/layout.py @@ -148,6 +148,7 @@ def build_layout(app_state: AppState, template_names: list[str] | None = None) - ), # --- Hidden stores --- dcc.Store(id="selected-channels-store", data=[]), + dcc.Store(id="focus-channel-store", data=None), dcc.Store(id="session-store", data={}), dcc.Store(id="page-refresh-trigger", data={}), dcc.Store(id="splitter-state", data={"width": 320}),