From a3007873a88c9030ee6cf4c3162864ee56df411e Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 00:48:16 -0400 Subject: [PATCH 01/11] feat(signals): Modify menu + dotted-field grammar; remove tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes on the Signals page query builder: Modify menu (UX): the always-visible chip row was reading as one long sentence with five optional knobs (breakout, rollup, reject, fill, label) crowded next to the core `Show of where ...`. Collapses to: Show of where [+ Modify ▾] with each modifier described in the dropdown before you add it: Group by name Split results into one series per signal name Rollup interval Override the automatic bucket width Reject outliers Drop raw samples by value or sigma before aggregating Fill empty buckets Choose what to show when a bucket has no data Name series Label the result so axis controls / expressions can refer to it Once added, each modifier renders inline as a removable clause; once removed, it returns to the menu. Dotted-field grammar: aggregator fields now read `signal.name` / `signal.value` / `signal.raw_value` instead of bare `signal` / `value` / `raw_value`. Filter columns stay bare (`where name = "x"`) — once inside a where clause the `signal.` namespace is redundant. Reject metrics follow the aggregator: `signal.value` / `signal.raw_value` are dotted, computed `sigma` stays bare. Hard cutover — the bare form errors out cleanly. Both the TS parser/serializer and the Python parser learn the new shape; the executor strips the `signal.` prefix when going from MQL to ClickHouse column names via a small _column() helper. Also: removed all test files + test infrastructure across the repo (vitest in dashboard, pytest in query / mapache-py / rigby) and the matching CI Test jobs. Per project convention this repo doesn't carry automated tests. --- .github/workflows/mapache-py.yml | 26 +- .github/workflows/query.yml | 19 - dashboard/package.json | 4 +- .../src/components/signals/MqlEditor.tsx | 4 +- .../src/components/signals/QueryBuilder.tsx | 210 ++++++++-- .../src/components/signals/SignalWidget.tsx | 4 +- .../__tests__/derived-named-series.test.ts | 150 -------- .../src/components/signals/chartTypes.ts | 4 +- dashboard/src/lib/__tests__/date.test.ts | 25 -- dashboard/src/lib/__tests__/format.test.ts | 50 --- dashboard/src/lib/__tests__/query.test.ts | 43 --- .../src/lib/__tests__/useTextMirror.test.ts | 37 -- dashboard/src/lib/query.ts | 94 +++-- .../sessions/__tests__/importMarkers.test.ts | 127 ------ .../lib/sessions/__tests__/lapInputs.test.ts | 128 ------- .../lib/sessions/__tests__/outliers.test.ts | 132 ------- dashboard/vitest.config.ts | 15 - mapache-py/pyproject.toml | 1 - mapache-py/tests/__init__.py | 0 mapache-py/tests/test_binary.py | 362 ------------------ mapache-py/tests/test_message.py | 279 -------------- mapache-py/tests/test_vehicle.py | 57 --- query/pyproject.toml | 18 - query/query/service/query_exec.py | 24 +- query/query/service/query_lang.py | 63 ++- query/tests/__init__.py | 0 query/tests/conftest.py | 18 - query/tests/test_query_exec.py | 267 ------------- query/tests/test_query_lang.py | 357 ----------------- query/tests/test_query_pairs.py | 137 ------- rigby/pyproject.toml | 10 - rigby/tests/__init__.py | 0 rigby/tests/nodes/gr24/bcm_test.py | 22 -- rigby/tests/nodes/gr24/gps_test.py | 14 - rigby/tests/nodes/gr24/pedal_test.py | 21 - rigby/tests/nodes/gr24/wheel_test.py | 37 -- rigby/tests/test.py | 17 - rigby/tests/utils/binary_test.py | 62 --- rigby/tests/utils/generator_test.py | 27 -- 39 files changed, 309 insertions(+), 2556 deletions(-) delete mode 100644 dashboard/src/components/signals/__tests__/derived-named-series.test.ts delete mode 100644 dashboard/src/lib/__tests__/date.test.ts delete mode 100644 dashboard/src/lib/__tests__/format.test.ts delete mode 100644 dashboard/src/lib/__tests__/query.test.ts delete mode 100644 dashboard/src/lib/__tests__/useTextMirror.test.ts delete mode 100644 dashboard/src/lib/sessions/__tests__/importMarkers.test.ts delete mode 100644 dashboard/src/lib/sessions/__tests__/lapInputs.test.ts delete mode 100644 dashboard/src/lib/sessions/__tests__/outliers.test.ts delete mode 100644 dashboard/vitest.config.ts delete mode 100644 mapache-py/tests/__init__.py delete mode 100644 mapache-py/tests/test_binary.py delete mode 100644 mapache-py/tests/test_message.py delete mode 100644 mapache-py/tests/test_vehicle.py delete mode 100644 query/tests/__init__.py delete mode 100644 query/tests/conftest.py delete mode 100644 query/tests/test_query_exec.py delete mode 100644 query/tests/test_query_lang.py delete mode 100644 query/tests/test_query_pairs.py delete mode 100644 rigby/tests/__init__.py delete mode 100644 rigby/tests/nodes/gr24/bcm_test.py delete mode 100644 rigby/tests/nodes/gr24/gps_test.py delete mode 100644 rigby/tests/nodes/gr24/pedal_test.py delete mode 100644 rigby/tests/nodes/gr24/wheel_test.py delete mode 100644 rigby/tests/test.py delete mode 100644 rigby/tests/utils/binary_test.py delete mode 100644 rigby/tests/utils/generator_test.py diff --git a/.github/workflows/mapache-py.yml b/.github/workflows/mapache-py.yml index a697afa7..9134663a 100644 --- a/.github/workflows/mapache-py.yml +++ b/.github/workflows/mapache-py.yml @@ -27,30 +27,8 @@ jobs: - name: Lint run: | - ruff format --check src/ tests/ - ruff check src/ tests/ + ruff format --check src/ + ruff check src/ - name: Type check run: mypy src/ - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: | - 3.10 - 3.11 - 3.12 - 3.13 - - - name: Test - run: | - for ver in 3.10 3.11 3.12 3.13; do - echo "::group::Python $ver" - python$ver -m pip install -e ".[dev]" - python$ver -m pytest tests/ -v - echo "::endgroup::" - done diff --git a/.github/workflows/query.yml b/.github/workflows/query.yml index fb498046..a6a4569e 100644 --- a/.github/workflows/query.yml +++ b/.github/workflows/query.yml @@ -9,25 +9,6 @@ on: - "v*" jobs: - test: - runs-on: ubuntu-latest - name: Test - defaults: - run: - working-directory: query - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install -e ".[dev]" - - - name: Test - run: pytest tests/ -v - build: runs-on: ${{ matrix.runner }} name: Build ${{ matrix.platform }} diff --git a/dashboard/package.json b/dashboard/package.json index 6bc02a20..d37c9fed 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "test": "vitest run", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "npx prettier --write .", "check": "npx prettier --check .", @@ -88,7 +87,6 @@ "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.4.21", - "vitest": "^2.1.9" + "vite": "^5.4.21" } } diff --git a/dashboard/src/components/signals/MqlEditor.tsx b/dashboard/src/components/signals/MqlEditor.tsx index a48cb6c8..8a5a209c 100644 --- a/dashboard/src/components/signals/MqlEditor.tsx +++ b/dashboard/src/components/signals/MqlEditor.tsx @@ -30,7 +30,7 @@ export function textToQueries(text: string, prev: QueryStmt[]): QueryStmt[] { .filter((l) => l !== ""); if (lines.length === 0) { // Never leave a widget with zero queries. - return [{ id: prev[0]?.id ?? newStmtId(), mql: "count(signal)" }]; + return [{ id: prev[0]?.id ?? newStmtId(), mql: "count(signal.name)" }]; } return lines.map((mql, i) => ({ id: prev[i]?.id ?? newStmtId(), mql })); } @@ -86,7 +86,7 @@ export function MqlEditor({ queries, onChange }: MqlEditorProps) { className={cn( "w-full resize-y rounded-md border bg-muted/30 px-2.5 py-2 font-mono text-xs leading-6 text-foreground/90 outline-none focus:border-primary/40", )} - placeholder={"count(signal).where(name = \"ecu*\") -> ecu\ncount(signal).where(name != \"ecu*\") -> other\necu / other -> ratio"} + placeholder={"count(signal.name).where(name = \"ecu*\") -> ecu\ncount(signal.name).where(name != \"ecu*\") -> other\necu / other -> ratio"} /> {lineErrors.length > 0 ? (
diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 1696f049..30329fb9 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -152,6 +152,28 @@ export function QueryBuilder({ (next) => onMqlChange?.(next), ); + // Optional modifier state — used to decide whether each chip renders + // inline or shows up as an "add me" entry in the Modify menu. + const activeModifiers = { + breakout, + rollup: value.rollup !== undefined, + reject: value.reject !== undefined, + fill: value.fill !== undefined, + label: value.label !== undefined, + }; + + // Sensible starting values when a modifier is freshly added from the + // Modify menu — chosen so the chip renders something useful immediately + // instead of an empty/placeholder state the user has to click into. + function addModifier(kind: ModifierKind) { + if (kind === "breakout") setBreakout(true); + else if (kind === "rollup") setRollup("1m"); + else if (kind === "reject") + setReject({ kind: "cmp", metric: "sigma", op: ">", threshold: 3 }); + else if (kind === "fill") setFill("gap"); + else if (kind === "label") setLabel("series"); + } + return (
{/* Reads as a sentence: Show of where … */} @@ -200,31 +222,43 @@ export function QueryBuilder({ - + {activeModifiers.breakout ? ( + setBreakout(false)}> + by name + + ) : null} - - - + {activeModifiers.rollup ? ( + setRollup(undefined)}> + + + ) : null} - - - + {activeModifiers.reject ? ( + setReject(undefined)}> + + + ) : null} - - - + {activeModifiers.fill ? ( + setFill(undefined)}> + + + ) : null} {/* `-> name` labels the single result series; hidden while breaking out (a breakdown is already labeled by its group values). */} - {!breakout ? ( - + {activeModifiers.label && !activeModifiers.breakout ? ( + setLabel(undefined)}> - + ) : null} + +
@@ -287,6 +321,119 @@ function Clause({ ); } +/** Clause + an X to remove the whole modifier. The X returns the + * corresponding entry to the Modify menu. */ +function RemovableClause({ + keyword, + children, + onRemove, +}: { + keyword: string; + children: ReactNode; + onRemove: () => void; +}) { + return ( + + {keyword} + {children} + + + ); +} + +type ModifierKind = "breakout" | "rollup" | "reject" | "fill" | "label"; + +/** The set of optional modifiers a query can carry, with the copy that + * surfaces in the Modify menu. Order here drives the menu order. */ +const MODIFIER_ITEMS: { + kind: ModifierKind; + label: string; + description: string; +}[] = [ + { + kind: "breakout", + label: "Group by name", + description: "Split results into one series per signal name.", + }, + { + kind: "rollup", + label: "Rollup interval", + description: "Override the automatic bucket width.", + }, + { + kind: "reject", + label: "Reject outliers", + description: "Drop raw samples by value or sigma before aggregating.", + }, + { + kind: "fill", + label: "Fill empty buckets", + description: "Choose what to show when a bucket has no data.", + }, + { + kind: "label", + label: "Name series", + description: "Label the result so axis controls / expressions can refer to it.", + }, +]; + +/** "+ Modify" — a popover menu listing every optional modifier not + * already on the query, with a one-line description per entry so the + * reader can see what it does before clicking. */ +function ModifyMenu({ + active, + onAdd, +}: { + active: Record; + onAdd: (kind: ModifierKind) => void; +}) { + const [open, setOpen] = useState(false); + const available = MODIFIER_ITEMS.filter((m) => !active[m.kind]); + if (available.length === 0) return null; + return ( + + + + + +
+ {available.map((m) => ( + + ))} +
+
+
+ ); +} + function Keyword({ children }: { children: ReactNode }) { return ( @@ -511,27 +658,6 @@ function FillChip({ ); } -// The single grouping choice (`.by(name)`): on = one series per matching -// signal, off = one combined series. -function BreakoutToggle({ - value, - onChange, -}: { - value: boolean; - onChange: (next: boolean) => void; -}) { - return ( - - ); -} // `-> name` chip: names the result series and exposes it as a variable. Unset // reads "name". Input is restricted to identifier chars by `setLabel`. @@ -610,7 +736,7 @@ function rejectToUi(node: RejectNode | undefined): RejectUiState { } else if (n.kind === "cmp" && n.metric === "sigma") { ui.sigmaOn = true; ui.sigmaN = n.threshold; - } else if (n.kind === "cmp" && (n.metric === "value" || n.metric === "raw_value")) { + } else if (n.kind === "cmp" && (n.metric === "signal.value" || n.metric === "signal.raw_value")) { if (n.op === "<" || n.op === "<=") ui.min = String(n.threshold); else if (n.op === ">" || n.op === ">=") ui.max = String(n.threshold); } else if (n.kind === "range" && !n.inside) { @@ -634,15 +760,15 @@ function uiToReject(ui: RejectUiState): RejectNode | undefined { if (hasMin && hasMax) { leaves.push({ kind: "range", - metric: "value", + metric: "signal.value", lo: Number(ui.min), hi: Number(ui.max), inside: false, }); } else if (hasMin) { - leaves.push({ kind: "cmp", metric: "value", op: "<", threshold: Number(ui.min) }); + leaves.push({ kind: "cmp", metric: "signal.value", op: "<", threshold: Number(ui.min) }); } else if (hasMax) { - leaves.push({ kind: "cmp", metric: "value", op: ">", threshold: Number(ui.max) }); + leaves.push({ kind: "cmp", metric: "signal.value", op: ">", threshold: Number(ui.max) }); } if (leaves.length === 0) return undefined; return leaves.reduce((left, right) => ({ kind: "bool", op: "or", left, right })); diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index 073d4c6f..e3769ce4 100644 --- a/dashboard/src/components/signals/SignalWidget.tsx +++ b/dashboard/src/components/signals/SignalWidget.tsx @@ -224,7 +224,7 @@ export function SignalWidget({ // statements evaluate in-browser over the fetched base series. The chip rows // and the raw MQL editor are two views of this one list. const [queries, setQueries] = useState([ - { id: newQueryId(), mql: "count(signal)" }, + { id: newQueryId(), mql: "count(signal.name)" }, ]); const [editAsMql, setEditAsMql] = useState(false); // While a row's field is focused, freeze its kind: `looksLikeFetchQuery` @@ -572,7 +572,7 @@ export function SignalWidget({ setQueries((prev) => prev.map((t) => (t.id === id ? { ...t, mql } : t))); const addQuery = () => - setQueries((prev) => [...prev, { id: newQueryId(), mql: "avg(value)" }]); + setQueries((prev) => [...prev, { id: newQueryId(), mql: "avg(signal.value)" }]); const removeQuery = (id: string) => setQueries((prev) => diff --git a/dashboard/src/components/signals/__tests__/derived-named-series.test.ts b/dashboard/src/components/signals/__tests__/derived-named-series.test.ts deleted file mode 100644 index 92d3b828..00000000 --- a/dashboard/src/components/signals/__tests__/derived-named-series.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - buildSeriesVariables, - compileAgainstSeries, - computeDerivedSeries, - type NamedSeries, -} from "@/lib/derived"; -import { DERIVED_KEY, type Series } from "@/components/signals/QueryChart"; -import type { DerivedTrace } from "@/lib/expr"; - -// A small bucketed series; the bucket label is opaque to the math (alignment is -// purely by index), so plain "b{i}" strings suffice. -function series(values: (number | null)[]): Series { - return { - tags: { name: "sig" }, - points: values.map((value, i) => ({ bucket: `b${i}`, value })), - }; -} - -function trace( - id: string, - expression: string, - name?: string, -): DerivedTrace & { name?: string } { - return { id, label: name ?? expression, expression, name }; -} - -describe("named-series variables (-> name as a first-class variable)", () => { - it("exposes an explicit -> name alongside the positional sN alias", () => { - const vars = buildSeriesVariables([ - { series: series([1]), name: "power" }, - { series: series([2]) }, - ]); - expect(vars[0].index).toBe("s0"); - expect(vars[0].name).toBe("power"); - expect(vars[1].index).toBe("s1"); - expect(vars[1].name).toBeNull(); - }); - - it("resolves and computes over explicit names (x + y)", () => { - const pool: NamedSeries[] = [ - { series: series([1, 2, 3]), name: "x" }, - { series: series([10, 20, 30]), name: "y" }, - ]; - const ev = compileAgainstSeries("x + y", pool); - expect(ev.ok).toBe(true); - expect(ev.evalAt?.(0)).toBe(11); - expect(ev.evalAt?.(1)).toBe(22); - expect(ev.evalAt?.(2)).toBe(33); - }); - - it("keeps positional sN aliases working for back-compat", () => { - const pool: NamedSeries[] = [ - { series: series([4, 5]), name: "x" }, - { series: series([6, 7]), name: "y" }, - ]; - const ev = compileAgainstSeries("s0 * s1", pool); - expect(ev.ok).toBe(true); - expect(ev.evalAt?.(0)).toBe(24); - expect(ev.evalAt?.(1)).toBe(35); - }); - - it("computes min/max over named series", () => { - const pool: NamedSeries[] = [ - { series: series([1, 9, 3]), name: "x" }, - { series: series([5, 2, 8]), name: "y" }, - ]; - const lo = compileAgainstSeries("min(x, y)", pool); - expect(lo.ok).toBe(true); - expect(lo.evalAt?.(0)).toBe(1); - expect(lo.evalAt?.(1)).toBe(2); - expect(lo.evalAt?.(2)).toBe(3); - - const hi = compileAgainstSeries("max(x, y)", pool); - expect(hi.ok).toBe(true); - expect(hi.evalAt?.(0)).toBe(5); - expect(hi.evalAt?.(1)).toBe(9); - expect(hi.evalAt?.(2)).toBe(8); - }); - - it("surfaces a duplicate -> name as a compile error (not a flat line)", () => { - const pool: NamedSeries[] = [ - { series: series([1, 2]), name: "x" }, - { series: series([3, 4]), name: "x" }, - ]; - const ev = compileAgainstSeries("x + 1", pool); - expect(ev.ok).toBe(false); - expect(ev.error).toMatch(/duplicate series name/i); - expect(ev.error).toMatch(/\bx\b/); - }); - - it("surfaces an unknown variable as a compile error", () => { - const pool: NamedSeries[] = [{ series: series([1, 2]), name: "x" }]; - const ev = compileAgainstSeries("x + bogus", pool); - expect(ev.ok).toBe(false); - expect(ev.error).toMatch(/unknown variable/i); - expect(ev.error).toMatch(/bogus/); - }); -}); - -describe("computeDerivedSeries (named results referenceable downstream)", () => { - const base = [series([2, 4, 6]), series([1, 2, 3])]; - - it("lets a later expression reference an earlier -> name", () => { - const traces = [ - trace("t0", "s0", "a"), // a = [2,4,6] - trace("t1", "s1", "b"), // b = [1,2,3] - trace("t2", "a + b"), // [3,6,9] - ]; - const results = computeDerivedSeries(base, traces); - expect(results.find((r) => r.id === "t0")?.error).toBeUndefined(); - expect(results.find((r) => r.id === "t1")?.error).toBeUndefined(); - const sum = results.find((r) => r.id === "t2"); - expect(sum?.error).toBeUndefined(); - expect(sum?.series?.points.map((p) => p.value)).toEqual([3, 6, 9]); - expect(sum?.series?.tags[DERIVED_KEY]).toBe("a + b"); - }); - - it("computes min(x, y) -> lo over two named series", () => { - const traces = [ - trace("t0", "s0", "x"), // [2,4,6] - trace("t1", "s1", "y"), // [1,2,3] - trace("t2", "min(x, y)", "lo"), // [1,2,3] - ]; - const results = computeDerivedSeries(base, traces); - const lo = results.find((r) => r.id === "t2"); - expect(lo?.error).toBeUndefined(); - expect(lo?.series?.points.map((p) => p.value)).toEqual([1, 2, 3]); - }); - - it("flags a duplicate -> name on the offending row even as the final pair", () => { - const traces = [ - trace("t0", "s0", "dup"), - trace("t1", "s1", "dup"), - ]; - const results = computeDerivedSeries(base, traces); - expect(results.find((r) => r.id === "t0")?.error).toBeUndefined(); - const second = results.find((r) => r.id === "t1"); - expect(second?.error).toMatch(/duplicate series name/i); - expect(second?.series).toBeUndefined(); - }); - - it("flags an unknown variable in a derived expression", () => { - const traces = [trace("t0", "s0 + nope", "out")]; - const results = computeDerivedSeries(base, traces); - const r = results.find((r) => r.id === "t0"); - expect(r?.error).toMatch(/unknown variable/i); - expect(r?.error).toMatch(/nope/); - }); -}); diff --git a/dashboard/src/components/signals/chartTypes.ts b/dashboard/src/components/signals/chartTypes.ts index 8a751ad8..23fa179a 100644 --- a/dashboard/src/components/signals/chartTypes.ts +++ b/dashboard/src/components/signals/chartTypes.ts @@ -45,7 +45,7 @@ export interface ChartTypeDef { /** Default a fresh widget / a compatible run-path switch lands on. */ function defaultRunQueries(): QueryStmt[] { - return [{ id: newStmtId(), mql: "count(signal)" }]; + return [{ id: newStmtId(), mql: "count(signal.name)" }]; } /** `n` empty-name trace lines for the pairs path (2 for scatter/path, 3 for 3D), @@ -54,7 +54,7 @@ function defaultPairQueries(n: number): () => QueryStmt[] { return () => Array.from({ length: n }, () => ({ id: newStmtId(), - mql: 'avg(value).where(name = "")', + mql: 'avg(signal.value).where(name = "")', })); } diff --git a/dashboard/src/lib/__tests__/date.test.ts b/dashboard/src/lib/__tests__/date.test.ts deleted file mode 100644 index 321a220c..00000000 --- a/dashboard/src/lib/__tests__/date.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { dayKey, parseDayKeys } from "@/lib/date"; - -describe("dayKey", () => { - it("formats a Date to a local yyyy-MM-dd key", () => { - expect(dayKey(new Date(2025, 5, 6))).toBe("2025-06-06"); - }); - - it("formats an ISO string to the same key as its Date", () => { - const iso = "2025-06-06T13:45:00"; - expect(dayKey(iso)).toBe(dayKey(new Date(iso))); - expect(dayKey(iso)).toBe("2025-06-06"); - }); - - it("zero-pads month and day", () => { - expect(dayKey(new Date(2025, 0, 1))).toBe("2025-01-01"); - }); -}); - -describe("parseDayKeys", () => { - it("round-trips through dayKey as local days", () => { - const keys = ["2025-06-06", "2025-01-01", "2024-12-31"]; - expect(parseDayKeys(keys).map(dayKey)).toEqual(keys); - }); -}); diff --git a/dashboard/src/lib/__tests__/format.test.ts b/dashboard/src/lib/__tests__/format.test.ts deleted file mode 100644 index aee86b87..00000000 --- a/dashboard/src/lib/__tests__/format.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { formatMetric } from "@/lib/format"; - -// The legacy per-site formatter formatMetric replaces (QueryChart/SignalWidget/ -// SignalsPage all shared this k/M shape; QueryChart additionally formatted -// sub-1000 fractions to 2 decimals, which is the superset kept here). -function legacy(n: number): string { - const abs = Math.abs(n); - if (abs < 1_000) return Number.isInteger(n) ? n.toString() : n.toFixed(2); - if (abs < 1_000_000) return `${(n / 1_000).toFixed(1)}k`; - return `${(n / 1_000_000).toFixed(2)}M`; -} - -describe("formatMetric", () => { - it("formats sub-1000 integers verbatim", () => { - expect(formatMetric(0)).toBe("0"); - expect(formatMetric(42)).toBe("42"); - expect(formatMetric(999)).toBe("999"); - }); - - it("formats sub-1000 fractions to two decimals", () => { - expect(formatMetric(3.14159)).toBe("3.14"); - expect(formatMetric(0.5)).toBe("0.50"); - }); - - it("abbreviates thousands with one decimal", () => { - expect(formatMetric(1_000)).toBe("1.0k"); - expect(formatMetric(12_345)).toBe("12.3k"); - expect(formatMetric(999_999)).toBe("1000.0k"); - }); - - it("abbreviates millions with two decimals", () => { - expect(formatMetric(1_000_000)).toBe("1.00M"); - expect(formatMetric(1_250_000)).toBe("1.25M"); - }); - - it("preserves sign via magnitude", () => { - expect(formatMetric(-42)).toBe("-42"); - expect(formatMetric(-3.14159)).toBe("-3.14"); - expect(formatMetric(-12_345)).toBe("-12.3k"); - }); - - it("matches the legacy per-site formatter across a sweep", () => { - const samples = [ - 0, 1, 42, 999, 1000, 1234, 9999, 12_345, 250_000, 999_999, 1_000_000, - 1_250_000, 42_000_000, -1, -1234, -2_500_000, 3.14159, 0.001, -0.5, - ]; - for (const n of samples) expect(formatMetric(n)).toBe(legacy(n)); - }); -}); diff --git a/dashboard/src/lib/__tests__/query.test.ts b/dashboard/src/lib/__tests__/query.test.ts deleted file mode 100644 index 2195f598..00000000 --- a/dashboard/src/lib/__tests__/query.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { looksLikeFetchQuery } from "@/lib/query"; - -describe("looksLikeFetchQuery", () => { - it("classifies an aggregator over a known field as a fetch", () => { - expect(looksLikeFetchQuery("min(value)")).toBe(true); - expect(looksLikeFetchQuery("max(raw_value)")).toBe(true); - expect(looksLikeFetchQuery("count(signal)")).toBe(true); - expect(looksLikeFetchQuery("avg(value)")).toBe(true); - }); - - it("does NOT classify a min/max derived expression as a fetch", () => { - // The regression: min/max are also expr.ts functions. A multi-arg call - // whose first arg isn't a field must route to the expression evaluator. - expect(looksLikeFetchQuery("min(s0, s1) -> lo")).toBe(false); - expect(looksLikeFetchQuery("max(a, b)")).toBe(false); - expect(looksLikeFetchQuery("min(s0, s1)")).toBe(false); - }); - - it("treats a known-field fetch as a fetch even with trailing methods/labels", () => { - expect(looksLikeFetchQuery('count(signal).where(name = "ecu*")')).toBe(true); - expect(looksLikeFetchQuery("count(signal).by(name)")).toBe(true); - expect(looksLikeFetchQuery('avg(value).where(name = "x") -> v')).toBe(true); - }); - - it("keeps a field-valid but otherwise-broken fetch as a fetch (inline error)", () => { - // First arg is a known field, so it stays a fetch; the parser surfaces the - // method typo inline rather than misrouting to the expression evaluator. - expect(looksLikeFetchQuery("avg(value).wherx(1)")).toBe(true); - }); - - it("routes non-aggregator and unknown-field calls to expr", () => { - expect(looksLikeFetchQuery("s0 + s1")).toBe(false); - expect(looksLikeFetchQuery("abs(s0)")).toBe(false); // abs: expr-only fn - expect(looksLikeFetchQuery("avg(speed)")).toBe(false); // unknown field - expect(looksLikeFetchQuery("(s0 + s1) / 2 -> mid")).toBe(false); - }); - - it("handles whitespace inside the call", () => { - expect(looksLikeFetchQuery(" min( value ) ")).toBe(true); - expect(looksLikeFetchQuery("min( s0 , s1 )")).toBe(false); - }); -}); diff --git a/dashboard/src/lib/__tests__/useTextMirror.test.ts b/dashboard/src/lib/__tests__/useTextMirror.test.ts deleted file mode 100644 index f93e4fad..00000000 --- a/dashboard/src/lib/__tests__/useTextMirror.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mirrorNext } from "@/lib/useTextMirror"; - -// Exercises the caret-preservation decision the hook delegates to. Rendering -// the hook itself isn't possible under the node test environment, so the rule -// is tested in isolation. -describe("mirrorNext", () => { - it("overwrites the buffer on an external change", () => { - const r = mirrorNext("b", "a", false); - expect(r).toEqual({ overwrite: true, text: "b", lastSeen: "b" }); - }); - - it("never overwrites on a self-edit echo (preserves caret)", () => { - // The user typed; the resulting serialized value arrives. Even though it - // differs from lastSeen, we must not clobber the local text. - const r = mirrorNext("count(x)", "count(", true); - expect(r.overwrite).toBe(false); - // ...but it records the value so the NEXT external change is detected. - expect(r.lastSeen).toBe("count(x)"); - }); - - it("does nothing when the serialized value is unchanged", () => { - const r = mirrorNext("same", "same", false); - expect(r).toEqual({ overwrite: false, lastSeen: "same" }); - }); - - it("after a self-edit, a later genuine external change overwrites", () => { - // First: self-edit echo for "a" — consumed, no overwrite. - let lastSeen = "a"; - const echo = mirrorNext("a", lastSeen, true); - lastSeen = echo.lastSeen; - expect(echo.overwrite).toBe(false); - // Then: parent pushes "z" with no self-edit — must overwrite. - const external = mirrorNext("z", lastSeen, false); - expect(external).toEqual({ overwrite: true, text: "z", lastSeen: "z" }); - }); -}); diff --git a/dashboard/src/lib/query.ts b/dashboard/src/lib/query.ts index ec5ae49a..4b96fc63 100644 --- a/dashboard/src/lib/query.ts +++ b/dashboard/src/lib/query.ts @@ -28,10 +28,14 @@ export const AGGREGATORS: { value: Aggregator; label: string }[] = [ /** Aggregators that operate on row-counts (only `count`, on `signal`). */ export const ROW_COUNT_AGGS: ReadonlySet = new Set(["count"]); -export type FieldName = "signal" | "value" | "raw_value"; +// Aggregator fields are dotted references onto a signal row: `signal.name` +// counts rows (one per signal occurrence), `signal.value` / `signal.raw_value` +// are the numeric columns. Filter columns stay bare (`name`) — once inside a +// where clause, the `signal.` namespace is redundant. +export type FieldName = "signal.name" | "signal.value" | "signal.raw_value"; -export const NUMERIC_FIELDS: FieldName[] = ["value", "raw_value"]; -export const COUNT_FIELD: FieldName = "signal"; +export const NUMERIC_FIELDS: FieldName[] = ["signal.value", "signal.raw_value"]; +export const COUNT_FIELD: FieldName = "signal.name"; export const FILTERABLE_COLUMNS = ["name"] as const; export const GROUPABLE_COLUMNS = ["name"] as const; @@ -52,9 +56,11 @@ export type Rollup = (typeof ROLLUP_INTERVALS)[number]; export const FILL_MODES = ["gap", "last", "linear"] as const; export type FillMode = (typeof FILL_MODES)[number]; -/** Metrics a `.reject(...)` leaf can compare. `sigma` = distance from the - * group mean in standard deviations (computed server-side). */ -export const REJECT_METRICS = ["value", "raw_value", "sigma"] as const; +/** Metrics a `.reject(...)` leaf can compare. `signal.value` / + * `signal.raw_value` are dotted to match the aggregator-field grammar. + * `sigma` stays bare — it's a computed distance from the group mean in + * standard deviations (server-side), not a column on the signal row. */ +export const REJECT_METRICS = ["signal.value", "signal.raw_value", "sigma"] as const; export type RejectMetric = (typeof REJECT_METRICS)[number]; export type ComparisonOp = ">" | ">=" | "<" | "<=" | "=" | "!="; @@ -62,7 +68,7 @@ export type ComparisonOp = ">" | ">=" | "<" | "<=" | "=" | "!="; /** Reject-condition tree, mirroring query_lang.py's RejectNode. */ export type RejectNode = | { kind: "cmp"; metric: RejectMetric; op: ComparisonOp; threshold: number } - | { kind: "range"; metric: "value" | "raw_value"; lo: number; hi: number; inside: boolean } + | { kind: "range"; metric: "signal.value" | "signal.raw_value"; lo: number; hi: number; inside: boolean } | { kind: "bool"; op: "and" | "or"; left: RejectNode; right: RejectNode }; export interface Predicate { @@ -87,14 +93,14 @@ export interface Query { export const DEFAULT_QUERY: Query = { fn: "count", - field: "signal", + field: "signal.name", filters: [], groupBy: [], }; -/** Default field for an aggregator: count → `signal`, else `value`. */ +/** Default field for an aggregator: count → `signal.name`, else `signal.value`. */ export function defaultFieldFor(fn: Aggregator): FieldName { - return ROW_COUNT_AGGS.has(fn) ? COUNT_FIELD : "value"; + return ROW_COUNT_AGGS.has(fn) ? COUNT_FIELD : "signal.value"; } /** Render the AST to canonical MQL; round-trips losslessly through parseQuery. */ @@ -354,6 +360,26 @@ export function parseQuery(input: string): ParseResult { } } +// Consume a dotted field reference like `signal.value`. Always two idents +// joined by a `.` punct; bare idents are rejected so the grammar surfaces +// a clear error instead of silently falling back to legacy behavior. +function parseDottedField(c: MqlCursor): { value: string; pos: number } { + const head = c.expectIdent(); + const next = c.peek(); + if (!next || next.kind !== "punct" || next.value !== ".") { + throw new MqlParseError( + `expected a dotted field like 'signal.value'`, + head.pos, + ); + } + c.advance(); // consume '.' + const tail = c.expectIdent(); + return { + value: `${head.value.toLowerCase()}.${tail.value.toLowerCase()}`, + pos: head.pos, + }; +} + function parseAggCall(c: MqlCursor): { fn: Aggregator; field: FieldName } { const fnTok = c.expectIdent(); const fn = fnTok.value.toLowerCase(); @@ -364,21 +390,21 @@ function parseAggCall(c: MqlCursor): { fn: Aggregator; field: FieldName } { fnTok.pos, ); c.expectPunct("("); - const fieldTok = c.expectIdent(); - const field = fieldTok.value.toLowerCase(); + const fieldRef = parseDottedField(c); c.expectPunct(")"); + const field = fieldRef.value; const allFields = new Set([COUNT_FIELD, ...NUMERIC_FIELDS]); if (!allFields.has(field)) - throw new MqlParseError(`unknown field '${fieldTok.value}'`, fieldTok.pos); + throw new MqlParseError(`unknown field '${field}'`, fieldRef.pos); const needsNumeric = !ROW_COUNT_AGGS.has(fn as Aggregator); if (needsNumeric && !NUMERIC_FIELDS.includes(field as FieldName)) throw new MqlParseError( `function '${fn}' needs a numeric field (${NUMERIC_FIELDS.join(", ")}), not '${field}'`, - fieldTok.pos, + fieldRef.pos, ); if (!needsNumeric && field !== COUNT_FIELD) - throw new MqlParseError(`function '${fn}' operates on rows; use '${COUNT_FIELD}'`, fieldTok.pos); + throw new MqlParseError(`function '${fn}' operates on rows; use '${COUNT_FIELD}'`, fieldRef.pos); return { fn: fn as Aggregator, field: field as FieldName }; } @@ -492,6 +518,23 @@ function parseRejectAnd(c: MqlCursor): RejectNode { } } +// Reject metrics are either a dotted field reference (signal.value / +// signal.raw_value) or the bare `sigma` keyword. Consume whichever shape +// the next tokens produce. +function parseRejectMetric(c: MqlCursor): { value: string; pos: number } { + const head = c.expectIdent(); + const next = c.peek(); + if (next && next.kind === "punct" && next.value === ".") { + c.advance(); + const tail = c.expectIdent(); + return { + value: `${head.value.toLowerCase()}.${tail.value.toLowerCase()}`, + pos: head.pos, + }; + } + return { value: head.value.toLowerCase(), pos: head.pos }; +} + function parseRejectCmp(c: MqlCursor): RejectNode { const t = c.peek(); if (t && t.kind === "punct" && t.value === "(") { @@ -500,10 +543,10 @@ function parseRejectCmp(c: MqlCursor): RejectNode { c.expectPunct(")"); return inner; } - const metricTok = c.expectIdent(); - const metric = metricTok.value.toLowerCase(); + const metricRef = parseRejectMetric(c); + const metric = metricRef.value; if (!REJECT_METRIC_SET.has(metric)) - throw new MqlParseError(`can't reject on '${metricTok.value}'`, metricTok.pos); + throw new MqlParseError(`can't reject on '${metric}'`, metricRef.pos); const nxt = c.peek(); if (nxt && nxt.kind === "ident" && (nxt.value.toLowerCase() === "between" || nxt.value.toLowerCase() === "outside")) { const kw = c.advance(); @@ -514,10 +557,10 @@ function parseRejectCmp(c: MqlCursor): RejectNode { c.expectPunct(","); const hi = parseRejectNumber(c); c.expectPunct(")"); - return { kind: "range", metric: metric as "value" | "raw_value", lo, hi, inside: kw.value.toLowerCase() === "between" }; + return { kind: "range", metric: metric as "signal.value" | "signal.raw_value", lo, hi, inside: kw.value.toLowerCase() === "between" }; } if (!nxt || nxt.kind !== "op") - throw new MqlParseError("expected a comparison (e.g. value > 100) or 'between'/'outside'", nxt ? nxt.pos : c.tailPos()); + throw new MqlParseError("expected a comparison (e.g. signal.value > 100) or 'between'/'outside'", nxt ? nxt.pos : c.tailPos()); if (!COMPARISON_OPS.has(nxt.value as ComparisonOp)) throw new MqlParseError(`invalid comparison operator '${nxt.value}'`, nxt.pos); c.advance(); @@ -540,12 +583,13 @@ const ALL_FIELD_SET = new Set([COUNT_FIELD, ...NUMERIC_FIELDS]); * `min`/`max` are BOTH aggregators (fetch) and expr.ts functions, so an * aggregator name alone can't discriminate: `min(s0, s1) -> lo` is a derived * expression, not a fetch. A line is a fetch only when it's a known aggregator - * whose first call argument is a known fetch field (signal/value/raw_value) — - * anything else (extra args, an unknown first arg) routes to the expression - * evaluator. A field-valid but otherwise-broken fetch still classifies as a - * fetch so its parse error surfaces inline rather than misrouting to expr. */ + * whose first call argument is a known dotted fetch field (signal.name / + * signal.value / signal.raw_value) — anything else (extra args, an unknown + * first arg) routes to the expression evaluator. A field-valid but + * otherwise-broken fetch still classifies as a fetch so its parse error + * surfaces inline rather than misrouting to expr. */ export function looksLikeFetchQuery(input: string): boolean { - const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/.exec( + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*)\s*\)/.exec( input, ); if (!m) return false; diff --git a/dashboard/src/lib/sessions/__tests__/importMarkers.test.ts b/dashboard/src/lib/sessions/__tests__/importMarkers.test.ts deleted file mode 100644 index 192735a4..00000000 --- a/dashboard/src/lib/sessions/__tests__/importMarkers.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { reprojectImportedSegments } from "@/lib/sessions/importMarkers"; -import { buildTransform } from "@/lib/sessions/geo"; -import type { GeoPoint } from "@/models/session"; - -// Two overlapping GPS clouds around the same base so the source and target -// transforms differ (different centroids) but project into the same geography. -const BASE_LAT = 34.41; -const BASE_LON = -119.85; - -function cloud(offsetLat = 0, offsetLon = 0): GeoPoint[] { - const pts: GeoPoint[] = []; - for (let i = 0; i < 5; i++) { - pts.push({ - lat: BASE_LAT + offsetLat + i * 1e-4, - lon: BASE_LON + offsetLon + i * 1e-4, - ts: i, - }); - } - return pts; -} - -describe("reprojectImportedSegments", () => { - it("round-trips a marker through source-geo into target XY", () => { - const sourceGeo = cloud(); - const targetGeo = cloud(); // identical cloud → identical transform - const srcT = buildTransform(sourceGeo); - const tgtT = buildTransform(targetGeo); - - // A marker line expressed in the SOURCE's normalized XY space: take two - // known geo points, push them through the source transform to get source XY. - const g1 = { lat: BASE_LAT + 1e-4, lon: BASE_LON + 1e-4 }; - const g2 = { lat: BASE_LAT + 2e-4, lon: BASE_LON + 2e-4 }; - const sx1 = srcT.toXY(g1.lat, g1.lon); - const sx2 = srcT.toXY(g2.lat, g2.lon); - - const segments = { "S/F": [sx1, sx2] }; - const bounds = { - minLat: BASE_LAT, - maxLat: BASE_LAT + 5e-4, - minLon: BASE_LON, - maxLon: BASE_LON + 5e-4, - }; - - const { projected, overlaps } = reprojectImportedSegments( - segments, - srcT, - tgtT, - bounds, - ); - - // S/F maps to segment number 1. - expect(Object.keys(projected)).toEqual(["1"]); - expect(projected[1]).toHaveLength(2); - - // Identical transforms → projected target XY equals the original source XY. - expect(projected[1][0][0]).toBeCloseTo(sx1[0], 6); - expect(projected[1][0][1]).toBeCloseTo(sx1[1], 6); - expect(projected[1][1][0]).toBeCloseTo(sx2[0], 6); - expect(projected[1][1][1]).toBeCloseTo(sx2[1], 6); - - expect(overlaps).toBe(true); - }); - - it("uses only the first two points of each segment", () => { - const geo = cloud(); - const t = buildTransform(geo); - const p = t.toXY(BASE_LAT + 1e-4, BASE_LON + 1e-4); - - const segments = { "S/F": [p, p, p, p] }; - const { projected } = reprojectImportedSegments(segments, t, t, null); - expect(projected[1]).toHaveLength(2); - }); - - it("ignores unknown segment names", () => { - const geo = cloud(); - const t = buildTransform(geo); - const p = t.toXY(BASE_LAT, BASE_LON); - - const { projected } = reprojectImportedSegments( - { bogus: [p, p] }, - t, - t, - null, - ); - expect(projected).toEqual({}); - }); - - it("reports no overlap when the marker lands outside the padded bounds", () => { - const sourceGeo = cloud(); - const srcT = buildTransform(sourceGeo); - const tgtT = buildTransform(cloud()); - - // A point far away (whole degree off) in source XY. - const far = srcT.toXY(BASE_LAT + 1, BASE_LON + 1); - const segments = { "S/F": [far, far] }; - const bounds = { - minLat: BASE_LAT, - maxLat: BASE_LAT + 5e-4, - minLon: BASE_LON, - maxLon: BASE_LON + 5e-4, - }; - - const { overlaps } = reprojectImportedSegments( - segments, - srcT, - tgtT, - bounds, - ); - expect(overlaps).toBe(false); - }); - - it("never overlaps when bounds is null but still projects", () => { - const geo = cloud(); - const t = buildTransform(geo); - const p = t.toXY(BASE_LAT + 1e-4, BASE_LON + 1e-4); - - const { projected, overlaps } = reprojectImportedSegments( - { "S/F": [p, p] }, - t, - t, - null, - ); - expect(overlaps).toBe(false); - expect(projected[1]).toHaveLength(2); - }); -}); diff --git a/dashboard/src/lib/sessions/__tests__/lapInputs.test.ts b/dashboard/src/lib/sessions/__tests__/lapInputs.test.ts deleted file mode 100644 index eec9c03f..00000000 --- a/dashboard/src/lib/sessions/__tests__/lapInputs.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { deriveLapInputs, tsToIso } from "@/lib/sessions/lapInputs"; -import type { LapResult, Point } from "@/models/session"; - -// Points at 1s spacing; only `ts` matters to deriveLapInputs (x/y are unused). -function pointsAt(timestamps: number[]): Point[] { - return timestamps.map((ts) => ({ x: 0, y: 0, ts })); -} - -// Minimal LapResult: the fields deriveLapInputs reads, with sane defaults for -// the rest so the shape stays valid. -function lapResult(partial: Partial): LapResult { - return { - lapCount: 0, - lapTimes: [], - bestTime: 0, - avgTime: 0, - worstTime: 0, - lapNumbers: [], - sectorNumbers: [], - crossingIndices: [], - ...partial, - }; -} - -describe("tsToIso", () => { - it("converts epoch seconds to an ISO string", () => { - expect(tsToIso(0)).toBe("1970-01-01T00:00:00.000Z"); - expect(tsToIso(1_600_000_000)).toBe("2020-09-13T12:26:40.000Z"); - }); -}); - -describe("deriveLapInputs", () => { - it("returns no laps when fewer than two crossings", () => { - expect(deriveLapInputs(lapResult({ crossingIndices: [] }), [])).toEqual([]); - expect( - deriveLapInputs( - lapResult({ crossingIndices: [0] }), - pointsAt([0, 1, 2]), - ), - ).toEqual([]); - }); - - it("derives one lap per crossing interval with ISO times and ms duration", () => { - // Crossings at index 0, 2, 4 → two laps. lapTimes drive duration_ms. - const points = pointsAt([100, 101, 102, 103, 104]); - const result = lapResult({ - crossingIndices: [0, 2, 4], - lapTimes: [2, 2], - bestTime: 2, - sectorNumbers: [1, 1, 1, 1, 1], - }); - - const laps = deriveLapInputs(result, points); - expect(laps).toHaveLength(2); - - expect(laps[0].lap_number).toBe(1); - expect(laps[0].start_time).toBe(tsToIso(100)); - expect(laps[0].end_time).toBe(tsToIso(102)); - expect(laps[0].duration_ms).toBe(2000); - - expect(laps[1].lap_number).toBe(2); - expect(laps[1].start_time).toBe(tsToIso(102)); - expect(laps[1].end_time).toBe(tsToIso(104)); - }); - - it("flags only the first lap matching bestTime as is_best", () => { - const points = pointsAt([0, 10, 20, 30]); - // Two laps tied at the best time of 10s. - const result = lapResult({ - crossingIndices: [0, 1, 2, 3], - lapTimes: [10, 10, 10], - bestTime: 10, - sectorNumbers: [1, 1, 1, 1], - }); - - const laps = deriveLapInputs(result, points); - expect(laps.map((l) => l.is_best)).toEqual([true, false, false]); - }); - - it("falls back to the index span when lapTimes is missing an entry", () => { - const points = pointsAt([0, 3, 8]); - const result = lapResult({ - crossingIndices: [0, 1, 2], - lapTimes: [3], // second lap's time absent → derive from ts span (8-3=5s) - bestTime: 3, - sectorNumbers: [1, 1, 1], - }); - - const laps = deriveLapInputs(result, points); - expect(laps[1].duration_ms).toBe(5000); - }); - - it("splits sectors at sector-number transitions within a lap", () => { - // One lap (crossings at 0 and 4) crossing sectors 1 -> 2 at index 2. - const points = pointsAt([0, 1, 2, 3, 4]); - const result = lapResult({ - crossingIndices: [0, 4], - lapTimes: [4], - bestTime: 4, - sectorNumbers: [1, 1, 2, 2, 2], - }); - - const laps = deriveLapInputs(result, points); - expect(laps[0].sectors).toEqual([ - { sector_number: 1, duration_ms: 2000 }, // ts 0 -> 2 - { sector_number: 2, duration_ms: 2000 }, // ts 2 -> 4 - ]); - }); - - it("skips zero-duration and non-positive sector segments", () => { - // A sector boundary that produces no elapsed time is dropped. - const points = pointsAt([0, 0, 5]); - const result = lapResult({ - crossingIndices: [0, 2], - lapTimes: [5], - bestTime: 5, - sectorNumbers: [1, 2, 2], - }); - - const laps = deriveLapInputs(result, points); - // The 1->2 transition at index 1 spans ts 0->0 (0ms) and is skipped; only - // the sector-2 run ts 0->5 survives. - expect(laps[0].sectors).toEqual([ - { sector_number: 2, duration_ms: 5000 }, - ]); - }); -}); diff --git a/dashboard/src/lib/sessions/__tests__/outliers.test.ts b/dashboard/src/lib/sessions/__tests__/outliers.test.ts deleted file mode 100644 index 2a1b70f3..00000000 --- a/dashboard/src/lib/sessions/__tests__/outliers.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { detectOutliers, OutlierConfig } from "@/lib/sessions/outliers"; -import { GeoPoint } from "@/models/session"; - -// A tight cluster near a base lat/lon (jitter is a few meters in degrees). -function cluster( - n: number, - baseLat = 34.41, - baseLon = -119.85, - jitterDeg = 1e-5, -): GeoPoint[] { - const pts: GeoPoint[] = []; - for (let i = 0; i < n; i++) { - // Deterministic pseudo-jitter so tests are stable. - const f = (i % 7) - 3; // -3..3 - pts.push({ - lat: baseLat + f * jitterDeg, - lon: baseLon + ((i % 5) - 2) * jitterDeg, - ts: i, - }); - } - return pts; -} - -const sigma = (sigmaN = 3): OutlierConfig => ({ - sigmaOn: true, - sigmaN, - maxDistanceM: null, -}); - -describe("detectOutliers", () => { - it("returns an all-false array when both detectors are off", () => { - const pts = cluster(10); - const flags = detectOutliers(pts, { - sigmaOn: false, - sigmaN: 3, - maxDistanceM: null, - }); - expect(flags).toHaveLength(10); - expect(flags.some(Boolean)).toBe(false); - }); - - it("handles empty input", () => { - expect(detectOutliers([], sigma())).toEqual([]); - }); - - it("flags a far-off point and leaves the cluster intact", () => { - const pts = cluster(30); - // ~1 km north — far outside the few-meter cluster. - pts.push({ lat: 34.42, lon: -119.85, ts: 99 }); - const flags = detectOutliers(pts, sigma(3)); - expect(flags[flags.length - 1]).toBe(true); - expect(flags.slice(0, 30).some(Boolean)).toBe(false); - }); - - it("flags both axes' outliers (true 2-D, not per-axis)", () => { - // A point offset diagonally in BOTH lat and lon — a per-axis test with a - // loose threshold could miss it; the radial test catches the combined - // distance. - const pts = cluster(30); - pts.push({ lat: 34.415, lon: -119.845, ts: 99 }); - const flags = detectOutliers(pts, sigma(3)); - expect(flags[flags.length - 1]).toBe(true); - }); - - it("is robust: a second outlier does not mask the first (no masking)", () => { - // With a non-robust mean/std, two far points inflate the std so neither is - // flagged. Median + MAD ignore them, so both stay flagged. - const pts = cluster(40); - pts.push({ lat: 34.42, lon: -119.85, ts: 100 }); - pts.push({ lat: 34.42, lon: -119.84, ts: 101 }); - const flags = detectOutliers(pts, sigma(3)); - expect(flags[flags.length - 1]).toBe(true); - expect(flags[flags.length - 2]).toBe(true); - expect(flags.slice(0, 40).some(Boolean)).toBe(false); - }); - - it("does not flag a clean cluster (no false positives)", () => { - const flags = detectOutliers(cluster(50), sigma(3)); - expect(flags.some(Boolean)).toBe(false); - }); - - it("does not flag everything when the spread is degenerate (MAD === 0)", () => { - // All identical points -> MAD 0. The sigma test must disable, not flag all. - const pts: GeoPoint[] = Array.from({ length: 10 }, (_, i) => ({ - lat: 34.41, - lon: -119.85, - ts: i, - })); - const flags = detectOutliers(pts, sigma(3)); - expect(flags.some(Boolean)).toBe(false); - }); - - it("honors the hard max-distance limit with sigma off", () => { - const pts = cluster(20); - // ~111 m east of the centroid (~0.001 deg lon * cos(34.41) * 111320). - pts.push({ lat: 34.41, lon: -119.85 + 0.0012, ts: 99 }); - const flags = detectOutliers(pts, { - sigmaOn: false, - sigmaN: 3, - maxDistanceM: 100, - }); - expect(flags[flags.length - 1]).toBe(true); - expect(flags.slice(0, 20).some(Boolean)).toBe(false); - }); - - it("does not flag points inside the hard limit", () => { - const pts = cluster(20); - // ~22 m east — inside a 100 m limit. - pts.push({ lat: 34.41, lon: -119.85 + 0.00024, ts: 99 }); - const flags = detectOutliers(pts, { - sigmaOn: false, - sigmaN: 3, - maxDistanceM: 100, - }); - expect(flags.some(Boolean)).toBe(false); - }); - - it("a tighter sigmaN flags more aggressively than a looser one", () => { - const pts = cluster(40); - // A moderate excursion: present but not extreme. - pts.push({ lat: 34.4103, lon: -119.85, ts: 99 }); - const tight = detectOutliers(pts, sigma(2)).filter(Boolean).length; - const loose = detectOutliers(pts, sigma(8)).filter(Boolean).length; - expect(tight).toBeGreaterThanOrEqual(loose); - }); - - it("returns flags parallel to the input length", () => { - const pts = cluster(13); - expect(detectOutliers(pts, sigma())).toHaveLength(13); - }); -}); diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts deleted file mode 100644 index f25cec4f..00000000 --- a/dashboard/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vitest/config"; -import path from "node:path"; - -// Unit tests run in Node (the MQL parser/router is pure TS with no DOM deps). -// The build (`tsc && vite build`) excludes test files, so they live outside the -// production typecheck and only run under `npm test`. -export default defineConfig({ - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, - test: { - environment: "node", - include: ["src/**/*.test.ts"], - }, -}); diff --git a/mapache-py/pyproject.toml b/mapache-py/pyproject.toml index 431f7871..bbd8a4e9 100644 --- a/mapache-py/pyproject.toml +++ b/mapache-py/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ [project.optional-dependencies] dev = [ - "pytest", "ruff", "mypy", ] diff --git a/mapache-py/tests/__init__.py b/mapache-py/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/mapache-py/tests/test_binary.py b/mapache-py/tests/test_binary.py deleted file mode 100644 index bd172893..00000000 --- a/mapache-py/tests/test_binary.py +++ /dev/null @@ -1,362 +0,0 @@ -import struct - -import pytest - -from mapache import ( - big_endian_bytes_to_signed_int, - big_endian_bytes_to_unsigned_int, - big_endian_signed_int_to_binary, - big_endian_signed_int_to_binary_string, - big_endian_unsigned_int_to_binary, - big_endian_unsigned_int_to_binary_string, - little_endian_bytes_to_signed_int, - little_endian_bytes_to_unsigned_int, - little_endian_signed_int_to_binary, - little_endian_signed_int_to_binary_string, - little_endian_unsigned_int_to_binary, - little_endian_unsigned_int_to_binary_string, -) - - -class TestBigEndianUnsignedIntToBinaryString: - def test_38134_1_byte(self) -> None: - with pytest.raises(ValueError): - big_endian_unsigned_int_to_binary_string(38134, 1) - - def test_38134_2_byte(self) -> None: - assert big_endian_unsigned_int_to_binary_string(38134, 2) == "1001010011110110" - - -class TestBigEndianUnsignedIntToBinary: - def test_negative_number(self) -> None: - with pytest.raises(ValueError): - big_endian_unsigned_int_to_binary(-1, 1) - - def test_0_bytes(self) -> None: - with pytest.raises(ValueError): - big_endian_unsigned_int_to_binary(100, 0) - - def test_number_too_large(self) -> None: - with pytest.raises(ValueError): - big_endian_unsigned_int_to_binary(31241, 1) - - def test_number_too_large_2(self) -> None: - with pytest.raises(ValueError): - big_endian_unsigned_int_to_binary(3172123, 2) - - def test_0_1_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(0, 1) == bytes([0]) - - def test_123_1_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(123, 1) == bytes([123]) - - def test_255_1_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(255, 1) == bytes([255]) - - def test_172_2_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(172, 2) == struct.pack(">H", 172) - - def test_38134_2_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(38134, 2) == struct.pack(">H", 38134) - - def test_429496295_4_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(429496295, 4) == struct.pack(">I", 429496295) - - def test_44009551615_6_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(44009551615, 6) == bytes([0, 10, 63, 44, 118, 255]) - - def test_18446744009551615_8_byte(self) -> None: - assert big_endian_unsigned_int_to_binary(18446744009551615, 8) == struct.pack(">Q", 18446744009551615) - - -class TestBigEndianSignedIntToBinaryString: - def test_32767_1_byte(self) -> None: - with pytest.raises(ValueError): - big_endian_signed_int_to_binary_string(32767, 1) - - def test_32767_2_byte(self) -> None: - assert big_endian_signed_int_to_binary_string(32767, 2) == "0111111111111111" - - -class TestBigEndianSignedIntToBinary: - def test_0_bytes(self) -> None: - with pytest.raises(ValueError): - big_endian_signed_int_to_binary(100, 0) - - def test_number_too_large(self) -> None: - with pytest.raises(ValueError): - big_endian_signed_int_to_binary(31241, 1) - - def test_number_too_large_2(self) -> None: - with pytest.raises(ValueError): - big_endian_signed_int_to_binary(3172123, 2) - - def test_0_1_byte(self) -> None: - assert big_endian_signed_int_to_binary(0, 1) == bytes([0]) - - def test_123_1_byte(self) -> None: - assert big_endian_signed_int_to_binary(123, 1) == bytes([123]) - - def test_255_1_byte(self) -> None: - with pytest.raises(ValueError): - big_endian_signed_int_to_binary(255, 1) - - def test_172_2_byte(self) -> None: - assert big_endian_signed_int_to_binary(172, 2) == struct.pack(">h", 172) - - def test_32767_2_byte(self) -> None: - assert big_endian_signed_int_to_binary(32767, 2) == struct.pack(">h", 32767) - - def test_neg_32767_2_byte(self) -> None: - assert big_endian_signed_int_to_binary(-32767, 2) == struct.pack(">h", -32767) - - def test_429496295_4_byte(self) -> None: - assert big_endian_signed_int_to_binary(429496295, 4) == struct.pack(">i", 429496295) - - def test_neg_429496295_4_byte(self) -> None: - assert big_endian_signed_int_to_binary(-429496295, 4) == struct.pack(">i", -429496295) - - def test_44009551615_6_byte(self) -> None: - assert big_endian_signed_int_to_binary(44009551615, 6) == bytes([0, 10, 63, 44, 118, 255]) - - def test_18446744009551615_8_byte(self) -> None: - assert big_endian_signed_int_to_binary(18446744009551615, 8) == struct.pack(">q", 18446744009551615) - - def test_neg_18446744009551615_8_byte(self) -> None: - assert big_endian_signed_int_to_binary(-18446744009551615, 8) == struct.pack(">q", -18446744009551615) - - -class TestBigEndianBytesToUnsignedInt: - def test_0_1_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([0])) == 0 - - def test_123_1_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([123])) == 123 - - def test_255_1_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([255])) == 255 - - def test_172_2_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([0, 172])) == 172 - - def test_38134_2_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([148, 246])) == 38134 - - def test_429496295_4_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([25, 153, 151, 231])) == 429496295 - - def test_44009551615_6_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([0, 10, 63, 44, 118, 255])) == 44009551615 - - def test_18446744009551615_8_byte(self) -> None: - assert big_endian_bytes_to_unsigned_int(bytes([0, 65, 137, 55, 71, 243, 174, 255])) == 18446744009551615 - - -class TestBigEndianBytesToSignedInt: - def test_0_1_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([0])) == 0 - - def test_123_1_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([123])) == 123 - - def test_255_1_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([255])) == -1 - - def test_172_2_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([0, 172])) == 172 - - def test_32767_2_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([127, 255])) == 32767 - - def test_neg_32767_2_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([128, 1])) == -32767 - - def test_429496295_4_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([25, 153, 151, 231])) == 429496295 - - def test_neg_429496295_4_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([230, 102, 104, 25])) == -429496295 - - def test_44009551615_6_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([0, 10, 63, 44, 118, 255])) == 44009551615 - - def test_279319963006464_6_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([255, 10, 63, 44, 118, 0])) == 279319963006464 - - def test_18446744009551615_8_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([0, 65, 137, 55, 71, 243, 174, 255])) == 18446744009551615 - - def test_neg_18446744009551615_8_byte(self) -> None: - assert big_endian_bytes_to_signed_int(bytes([255, 190, 118, 200, 184, 12, 81, 1])) == -18446744009551615 - - -class TestLittleEndianUnsignedIntToBinaryString: - def test_38134_1_byte(self) -> None: - with pytest.raises(ValueError): - little_endian_unsigned_int_to_binary_string(38134, 1) - - def test_38134_2_byte(self) -> None: - assert little_endian_unsigned_int_to_binary_string(38134, 2) == "1111011010010100" - - -class TestLittleEndianUnsignedIntToBinary: - def test_negative_number(self) -> None: - with pytest.raises(ValueError): - little_endian_unsigned_int_to_binary(-1, 1) - - def test_0_bytes(self) -> None: - with pytest.raises(ValueError): - little_endian_unsigned_int_to_binary(100, 0) - - def test_number_too_large(self) -> None: - with pytest.raises(ValueError): - little_endian_unsigned_int_to_binary(31241, 1) - - def test_number_too_large_2(self) -> None: - with pytest.raises(ValueError): - little_endian_unsigned_int_to_binary(3172123, 2) - - def test_0_1_byte(self) -> None: - assert little_endian_unsigned_int_to_binary(0, 1) == bytes([0]) - - def test_123_1_byte(self) -> None: - assert little_endian_unsigned_int_to_binary(123, 1) == bytes([123]) - - def test_255_1_byte(self) -> None: - assert little_endian_unsigned_int_to_binary(255, 1) == bytes([255]) - - def test_172_2_byte(self) -> None: - assert little_endian_unsigned_int_to_binary(172, 2) == struct.pack(" None: - assert little_endian_unsigned_int_to_binary(38134, 2) == struct.pack(" None: - assert little_endian_unsigned_int_to_binary(429496295, 4) == struct.pack(" None: - assert little_endian_unsigned_int_to_binary(44009551615, 6) == bytes([255, 118, 44, 63, 10, 0]) - - def test_18446744009551615_8_byte(self) -> None: - assert little_endian_unsigned_int_to_binary(18446744009551615, 8) == struct.pack(" None: - with pytest.raises(ValueError): - little_endian_signed_int_to_binary_string(32767, 1) - - def test_32767_2_byte(self) -> None: - assert little_endian_signed_int_to_binary_string(32767, 2) == "1111111101111111" - - -class TestLittleEndianSignedIntToBinary: - def test_0_bytes(self) -> None: - with pytest.raises(ValueError): - little_endian_signed_int_to_binary(100, 0) - - def test_number_too_large(self) -> None: - with pytest.raises(ValueError): - little_endian_signed_int_to_binary(31241, 1) - - def test_number_too_large_2(self) -> None: - with pytest.raises(ValueError): - little_endian_signed_int_to_binary(3172123, 2) - - def test_0_1_byte(self) -> None: - assert little_endian_signed_int_to_binary(0, 1) == bytes([0]) - - def test_123_1_byte(self) -> None: - assert little_endian_signed_int_to_binary(123, 1) == bytes([123]) - - def test_255_1_byte(self) -> None: - with pytest.raises(ValueError): - little_endian_signed_int_to_binary(255, 1) - - def test_172_2_byte(self) -> None: - assert little_endian_signed_int_to_binary(172, 2) == struct.pack(" None: - assert little_endian_signed_int_to_binary(32767, 2) == struct.pack(" None: - assert little_endian_signed_int_to_binary(-32767, 2) == struct.pack(" None: - assert little_endian_signed_int_to_binary(429496295, 4) == struct.pack(" None: - assert little_endian_signed_int_to_binary(-429496295, 4) == struct.pack(" None: - assert little_endian_signed_int_to_binary(44009551615, 6) == bytes([255, 118, 44, 63, 10, 0]) - - def test_18446744009551615_8_byte(self) -> None: - assert little_endian_signed_int_to_binary(18446744009551615, 8) == struct.pack(" None: - assert little_endian_signed_int_to_binary(-18446744009551615, 8) == struct.pack(" None: - assert little_endian_bytes_to_unsigned_int(bytes([0])) == 0 - - def test_123_1_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([123])) == 123 - - def test_255_1_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([255])) == 255 - - def test_172_2_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([172, 0])) == 172 - - def test_38134_2_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([246, 148])) == 38134 - - def test_429496295_4_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([231, 151, 153, 25])) == 429496295 - - def test_44009551615_6_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([255, 118, 44, 63, 10, 0])) == 44009551615 - - def test_18446744009551615_8_byte(self) -> None: - assert little_endian_bytes_to_unsigned_int(bytes([255, 174, 243, 71, 55, 137, 65, 0])) == 18446744009551615 - - -class TestLittleEndianBytesToSignedInt: - def test_0_1_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([0])) == 0 - - def test_123_1_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([123])) == 123 - - def test_255_1_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([255])) == -1 - - def test_172_2_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([172, 0])) == 172 - - def test_32767_2_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([255, 127])) == 32767 - - def test_neg_32767_2_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([1, 128])) == -32767 - - def test_429496295_4_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([231, 151, 153, 25])) == 429496295 - - def test_neg_429496295_4_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([25, 104, 102, 230])) == -429496295 - - def test_44009551615_6_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([255, 118, 44, 63, 10, 0])) == 44009551615 - - def test_279319963006464_6_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([0, 118, 44, 63, 10, 255])) == 279319963006464 - - def test_18446744009551615_8_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([255, 174, 243, 71, 55, 137, 65, 0])) == 18446744009551615 - - def test_neg_18446744009551615_8_byte(self) -> None: - assert little_endian_bytes_to_signed_int(bytes([1, 81, 12, 184, 200, 118, 190, 255])) == -18446744009551615 diff --git a/mapache-py/tests/test_message.py b/mapache-py/tests/test_message.py deleted file mode 100644 index f810c8a7..00000000 --- a/mapache-py/tests/test_message.py +++ /dev/null @@ -1,279 +0,0 @@ -import pytest - -from mapache import Endian, Field, Message, Signal, SignMode, new_field - - -def _ecu_status_message() -> Message: - return Message( - fields=[ - new_field("ecu_state", 1, SignMode.UNSIGNED, Endian.BIG_ENDIAN), - new_field( - "ecu_status_flags", - 3, - SignMode.UNSIGNED, - Endian.BIG_ENDIAN, - lambda f: [ - Signal( - name=name, - value=float(f.check_bit(i)), - raw_value=f.check_bit(i), - ) - for i, name in enumerate( - [ - "ecu_status_acu", - "ecu_status_inv_one", - "ecu_status_inv_two", - "ecu_status_inv_three", - "ecu_status_inv_four", - "ecu_status_fan_one", - "ecu_status_fan_two", - "ecu_status_fan_three", - "ecu_status_fan_four", - "ecu_status_fan_five", - "ecu_status_fan_six", - "ecu_status_fan_seven", - "ecu_status_fan_eight", - "ecu_status_dash", - "ecu_status_steering", - ] - ) - ], - ), - new_field( - "ecu_maps", - 1, - SignMode.UNSIGNED, - Endian.BIG_ENDIAN, - lambda f: [ - Signal(name="ecu_power_level", value=float((f.value >> 4) & 0x0F), raw_value=(f.value >> 4) & 0x0F), - Signal(name="ecu_torque_map", value=float(f.value & 0x0F), raw_value=f.value & 0x0F), - ], - ), - new_field( - "ecu_max_cell_temp", - 1, - SignMode.UNSIGNED, - Endian.BIG_ENDIAN, - lambda f: [ - Signal(name="ecu_max_cell_temp", value=float(f.value) * 0.25, raw_value=f.value), - ], - ), - new_field( - "ecu_acu_state_of_charge", - 1, - SignMode.UNSIGNED, - Endian.BIG_ENDIAN, - lambda f: [ - Signal(name="ecu_acu_state_of_charge", value=float(f.value) * 20 / 51, raw_value=f.value), - ], - ), - new_field( - "ecu_glv_state_of_charge", - 1, - SignMode.UNSIGNED, - Endian.BIG_ENDIAN, - lambda f: [ - Signal(name="ecu_glv_state_of_charge", value=float(f.value) * 20 / 51, raw_value=f.value), - ], - ), - ] - ) - - -class TestMessage: - def test_invalid_byte_length(self) -> None: - msg = _ecu_status_message() - with pytest.raises(ValueError): - msg.fill_from_bytes(bytes([0, 0])) - - def test_zero_values(self) -> None: - msg = _ecu_status_message() - msg.fill_from_bytes(bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) - signals = msg.export_signals() - for signal in signals: - assert signal.value == 0 - assert signal.raw_value == 0 - - def test_nonzero_values(self) -> None: - msg = _ecu_status_message() - msg.fill_from_bytes(bytes([0x12, 0x42, 0xFF, 0x00, 0x31, 0x82, 0x58, 0x72])) - signals = msg.export_signals() - expected_values = [ - 18, - 0, - 1, - 0, - 0, - 0, - 0, - 1, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 3, - 1, - 32.5, - 34.509804, - 44.705882, - ] - for i, signal in enumerate(signals): - assert int(signal.value) == int(expected_values[i]) - - -class TestNewField: - def test_basic(self) -> None: - field = new_field("test", 1, SignMode.UNSIGNED, Endian.BIG_ENDIAN) - assert field.size == 1 - assert field.sign == SignMode.UNSIGNED - - -class TestDecode: - @pytest.mark.parametrize( - "name,field_kwargs,expected", - [ - ( - "Signed BigEndian Positive", - dict(data=bytes([0x12, 0x34]), size=2, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - 0x1234, - ), - ( - "Signed BigEndian Negative", - dict(data=bytes([0xFF, 0xFE]), size=2, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - -2, - ), - ( - "Signed LittleEndian Positive", - dict(data=bytes([0x34, 0x12]), size=2, sign=SignMode.SIGNED, endian=Endian.LITTLE_ENDIAN), - 0x1234, - ), - ( - "Signed LittleEndian Negative", - dict(data=bytes([0xFE, 0xFF]), size=2, sign=SignMode.SIGNED, endian=Endian.LITTLE_ENDIAN), - -2, - ), - ( - "Unsigned BigEndian", - dict(data=bytes([0xFF, 0xFE]), size=2, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN), - 0xFFFE, - ), - ( - "Unsigned LittleEndian", - dict(data=bytes([0xFE, 0xFF]), size=2, sign=SignMode.UNSIGNED, endian=Endian.LITTLE_ENDIAN), - 0xFFFE, - ), - ( - "Single Byte Signed Positive", - dict(data=bytes([0x7F]), size=1, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - 127, - ), - ( - "Single Byte Signed Negative", - dict(data=bytes([0xCF]), size=1, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - -49, - ), - ( - "Four Bytes Unsigned BigEndian", - dict(data=bytes([0x12, 0x34, 0x56, 0x78]), size=4, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN), - 0x12345678, - ), - ( - "Four Bytes Unsigned LittleEndian", - dict(data=bytes([0x78, 0x56, 0x34, 0x12]), size=4, sign=SignMode.UNSIGNED, endian=Endian.LITTLE_ENDIAN), - 0x12345678, - ), - ], - ) - def test_decode(self, name: str, field_kwargs: dict, expected: int) -> None: # type: ignore[type-arg] - f = Field(**field_kwargs) - result = f.decode() - assert result.value == expected, f"{name}: expected {expected}, got {result.value}" - - -class TestEncode: - @pytest.mark.parametrize( - "name,field_kwargs,expected", - [ - ( - "Signed BigEndian Positive", - dict(value=0x1234, size=2, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - bytes([0x12, 0x34]), - ), - ( - "Signed BigEndian Negative", - dict(value=-2, size=2, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - bytes([0xFF, 0xFE]), - ), - ( - "Signed LittleEndian Positive", - dict(value=0x1234, size=2, sign=SignMode.SIGNED, endian=Endian.LITTLE_ENDIAN), - bytes([0x34, 0x12]), - ), - ( - "Signed LittleEndian Negative", - dict(value=-2, size=2, sign=SignMode.SIGNED, endian=Endian.LITTLE_ENDIAN), - bytes([0xFE, 0xFF]), - ), - ( - "Unsigned BigEndian", - dict(value=0xFFFE, size=2, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN), - bytes([0xFF, 0xFE]), - ), - ( - "Unsigned LittleEndian", - dict(value=0xFFFE, size=2, sign=SignMode.UNSIGNED, endian=Endian.LITTLE_ENDIAN), - bytes([0xFE, 0xFF]), - ), - ( - "Single Byte Signed Positive", - dict(value=127, size=1, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - bytes([0x7F]), - ), - ( - "Single Byte Signed Negative", - dict(value=-49, size=1, sign=SignMode.SIGNED, endian=Endian.BIG_ENDIAN), - bytes([0xCF]), - ), - ( - "Four Bytes Unsigned BigEndian", - dict(value=0x12345678, size=4, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN), - bytes([0x12, 0x34, 0x56, 0x78]), - ), - ( - "Four Bytes Unsigned LittleEndian", - dict(value=0x12345678, size=4, sign=SignMode.UNSIGNED, endian=Endian.LITTLE_ENDIAN), - bytes([0x78, 0x56, 0x34, 0x12]), - ), - ], - ) - def test_encode(self, name: str, field_kwargs: dict, expected: bytes) -> None: # type: ignore[type-arg] - f = Field(**field_kwargs) - result = f.encode() - assert result.data == expected, f"{name}: expected {expected!r}, got {result.data!r}" - - def test_value_too_large(self) -> None: - f = Field(value=0x1234, size=1, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN) - with pytest.raises(ValueError): - f.encode() - - def test_negative_unsigned(self) -> None: - f = Field(value=-1, size=2, sign=SignMode.UNSIGNED, endian=Endian.BIG_ENDIAN) - with pytest.raises(ValueError): - f.encode() - - def test_invalid_sign(self) -> None: - with pytest.raises(ValueError): - SignMode(3) - - -class TestCheckBit: - def test_check_bit(self) -> None: - test_bytes = bytes([0x12, 0x34]) - f = Field(data=test_bytes, size=len(test_bytes)) - for i in range(f.size * 8): - expected = (test_bytes[i // 8] >> (7 - i % 8)) & 1 - assert f.check_bit(i) == expected diff --git a/mapache-py/tests/test_vehicle.py b/mapache-py/tests/test_vehicle.py deleted file mode 100644 index fe4b4455..00000000 --- a/mapache-py/tests/test_vehicle.py +++ /dev/null @@ -1,57 +0,0 @@ -from datetime import datetime, timezone - -from mapache import Marker, Session, derive_segments - - -class TestDeriveSegmentsNoMarkers: - def test_single_segment(self) -> None: - start = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc) - session = Session(start_time=start, end_time=end) - - segments = derive_segments(session) - assert len(segments) == 1 - assert segments[0].number == 1 - assert segments[0].start_time == start - assert segments[0].end_time == end - - -class TestDeriveSegmentsOneMarker: - def test_two_segments(self) -> None: - start = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - mid = datetime(2026, 1, 1, 0, 30, 0, tzinfo=timezone.utc) - end = datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc) - session = Session(start_time=start, end_time=end, markers=[Marker(timestamp=mid)]) - - segments = derive_segments(session) - assert len(segments) == 2 - assert segments[0].number == 1 - assert segments[1].number == 2 - assert segments[0].start_time == start - assert segments[0].end_time == mid - assert segments[1].start_time == mid - assert segments[1].end_time == end - - -class TestDeriveSegmentsMultipleMarkers: - def test_four_segments_with_sorting(self) -> None: - start = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - m1 = datetime(2026, 1, 1, 0, 10, 0, tzinfo=timezone.utc) - m2 = datetime(2026, 1, 1, 0, 20, 0, tzinfo=timezone.utc) - m3 = datetime(2026, 1, 1, 0, 30, 0, tzinfo=timezone.utc) - end = datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc) - - session = Session( - start_time=start, - end_time=end, - markers=[Marker(timestamp=m3), Marker(timestamp=m1), Marker(timestamp=m2)], - ) - - segments = derive_segments(session) - assert len(segments) == 4 - for i, seg in enumerate(segments): - assert seg.number == i + 1 - assert segments[0].start_time == start and segments[0].end_time == m1 - assert segments[1].start_time == m1 and segments[1].end_time == m2 - assert segments[2].start_time == m2 and segments[2].end_time == m3 - assert segments[3].start_time == m3 and segments[3].end_time == end diff --git a/query/pyproject.toml b/query/pyproject.toml index 411f9eee..eb9be3f2 100644 --- a/query/pyproject.toml +++ b/query/pyproject.toml @@ -25,27 +25,9 @@ dependencies = [ "numpy>=2.4.6", ] -[project.optional-dependencies] -# pip-installable dev extras (used by CI's `pip install -e ".[dev]"`). -dev = [ - "pytest>=8.0,<9", - "httpx", -] - [project.scripts] query = "query.main:main" -[dependency-groups] -# uv-native dev group (used by `uv run pytest` / `uv sync`). Mirrors the -# pip-installable [project.optional-dependencies].dev above. -dev = [ - "pytest>=8.0,<9", - "httpx", -] - -[tool.pytest.ini_options] -testpaths = ["tests"] - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/query/query/service/query_exec.py b/query/query/service/query_exec.py index 17158164..041c89f1 100644 --- a/query/query/service/query_exec.py +++ b/query/query/service/query_exec.py @@ -35,6 +35,14 @@ from query.service.signals import INTERVALS, utc_iso +def _column(field: str) -> str: + """Map an MQL field/metric name (`signal.value`) to its underlying + ClickHouse column (`value`). Bare names (e.g. the computed `sigma`) + pass through unchanged so the rest of the SQL builder doesn't need + to special-case them.""" + return field.split(".", 1)[1] if "." in field else field + + def _build_filter_sql( filters: tuple, params: dict[str, Any] ) -> list[str]: @@ -118,7 +126,7 @@ def _build_reject_sql( if isinstance(node, RejectCmp): key = f"rj_{len(params)}" params[key] = node.threshold - metric_sql = _sigma_expr(sigma_col) if node.metric == "sigma" else node.metric + metric_sql = _sigma_expr(sigma_col) if node.metric == "sigma" else _column(node.metric) return f"{metric_sql} {node.op} {{{key}:Float64}}" if isinstance(node, RejectRange): @@ -126,7 +134,7 @@ def _build_reject_sql( params[lo_key] = node.lo hi_key = f"rj_{len(params)}" params[hi_key] = node.hi - m = node.metric + m = _column(node.metric) if node.inside: # `between`: reject inside [lo, hi] return f"({m} >= {{{lo_key}:Float64}} AND {m} <= {{{hi_key}:Float64}})" return f"({m} < {{{lo_key}:Float64}} OR {m} > {{{hi_key}:Float64}})" @@ -171,7 +179,7 @@ def run_query( interval_expr, step_ms = INTERVALS[effective_interval] - agg_sql = _FN_SQL[q.fn].format(field=q.field) + agg_sql = _FN_SQL[q.fn].format(field=_column(q.field)) select_parts = [f"toStartOfInterval(produced_at, {interval_expr}) AS bucket"] for col in q.group_by: @@ -206,8 +214,8 @@ def run_query( # group's mean/std, so it takes a window-stats subquery; value/raw_value # rejects just add a NOT(...) to the main WHERE. if q.reject is not None and _reject_uses_sigma(q.reject): - # count(signal) has no numeric field, so fall back to `value`. - sigma_col = q.field if q.field in ("value", "raw_value") else "value" + # count(signal.name) has no numeric field, so fall back to `value`. + sigma_col = _column(q.field) if q.field in ("signal.value", "signal.raw_value") else "value" # Score each series against its own population. if q.group_by: partition = "PARTITION BY " + ", ".join(q.group_by) @@ -282,9 +290,9 @@ def _run_reject_stats( with the param keys already bound by the main query. """ assert q.reject is not None - # Field the reject targets numerically; `count(signal)` has no numeric field - # so fall back to `value`, mirroring the executor's sigma_col logic. - field = q.field if q.field in ("value", "raw_value") else "value" + # Field the reject targets numerically; `count(signal.name)` has no numeric + # field so fall back to `value`, mirroring the executor's sigma_col logic. + field = _column(q.field) if q.field in ("signal.value", "signal.raw_value") else "value" params = dict(base_params) where = list(base_where) diff --git a/query/query/service/query_lang.py b/query/query/service/query_lang.py index efc567b8..b29c614d 100644 --- a/query/query/service/query_lang.py +++ b/query/query/service/query_lang.py @@ -46,8 +46,13 @@ "stddev": True, } -NUMERIC_FIELDS = {"value", "raw_value"} -COUNT_FIELD = "signal" +# Aggregator fields are dotted references onto a signal row. +# `signal.name` is the count target (one entry per signal occurrence); +# `signal.value` / `signal.raw_value` are the numeric columns. Filter +# columns stay bare (FILTERABLE_COLUMNS below) — once inside a where +# clause, the `signal.` namespace is redundant. +NUMERIC_FIELDS = {"signal.value", "signal.raw_value"} +COUNT_FIELD = "signal.name" ALL_FIELDS = NUMERIC_FIELDS | {COUNT_FIELD} # Narrow on purpose: vehicle_id is page-level and produced_at is @@ -74,7 +79,7 @@ # avg/stddevPop run OVER (PARTITION BY ) across the entire queried # window, not per time bucket. Rejection drops matching raw samples before # aggregation so a spike can't skew a bucket. -REJECT_METRICS = {"value", "raw_value", "sigma"} +REJECT_METRICS = {"signal.value", "signal.raw_value", "sigma"} _COMPARISON_OPS = {">", ">=", "<", "<=", "=", "!="} @@ -106,7 +111,7 @@ class RejectCmp: @dataclass(frozen=True) class RejectRange: - metric: str # "value" | "raw_value" (sigma ranges aren't meaningful) + metric: str # "signal.value" | "signal.raw_value" (sigma ranges aren't meaningful) lo: float hi: float inside: bool # True = `between` (reject inside); False = `outside` @@ -357,8 +362,24 @@ def parse(s: str) -> Query: ) +def _parse_dotted_field(c: _Cursor) -> tuple[str, int]: + """Consume a dotted field reference like `signal.value`. Always two idents + joined by a `.`; bare idents are rejected so the grammar surfaces a clear + error rather than silently falling back to the legacy behavior.""" + head = c.expect_ident() + nxt = c.peek() + if not nxt or nxt.kind != "punct" or nxt.value != ".": + raise QueryParseError( + "expected a dotted field like 'signal.value'", + head.pos, + ) + c.advance() + tail = c.expect_ident() + return f"{head.value.lower()}.{tail.value.lower()}", head.pos + + def _parse_aggregator_call(c: _Cursor) -> tuple[str, str]: - """Consume `()` from the head of the token stream.""" + """Consume `()` from the head of the token stream.""" fn_tok = c.expect_ident() fn = fn_tok.value.lower() if fn not in FUNCTIONS: @@ -369,15 +390,14 @@ def _parse_aggregator_call(c: _Cursor) -> tuple[str, str]: ) c.expect_punct("(") - field_tok = c.expect_ident() - field_name = field_tok.value.lower() + field_name, field_pos = _parse_dotted_field(c) c.expect_punct(")") if field_name not in ALL_FIELDS: raise QueryParseError( - f"unknown field '{field_tok.value}'; expected one of " + f"unknown field '{field_name}'; expected one of " + ", ".join(sorted(ALL_FIELDS)), - field_tok.pos, + field_pos, ) needs_numeric = FUNCTIONS[fn] @@ -385,13 +405,13 @@ def _parse_aggregator_call(c: _Cursor) -> tuple[str, str]: raise QueryParseError( f"function '{fn}' needs a numeric field " f"({', '.join(sorted(NUMERIC_FIELDS))}), not '{field_name}'", - field_tok.pos, + field_pos, ) if not needs_numeric and field_name != COUNT_FIELD: raise QueryParseError( f"function '{fn}' operates on rows; use '{COUNT_FIELD}' instead " f"of '{field_name}'", - field_tok.pos, + field_pos, ) return fn, field_name @@ -576,6 +596,18 @@ def _parse_reject_and(c: _Cursor) -> RejectNode: return left +def _parse_reject_metric(c: _Cursor) -> tuple[str, int]: + """Reject metric is either a dotted field (`signal.value` / + `signal.raw_value`) or the bare `sigma` keyword.""" + head = c.expect_ident() + nxt = c.peek() + if nxt and nxt.kind == "punct" and nxt.value == ".": + c.advance() + tail = c.expect_ident() + return f"{head.value.lower()}.{tail.value.lower()}", head.pos + return head.value.lower(), head.pos + + def _parse_reject_cmp(c: _Cursor) -> RejectNode: t = c.peek() if t and t.kind == "punct" and t.value == "(": @@ -584,13 +616,12 @@ def _parse_reject_cmp(c: _Cursor) -> RejectNode: c.expect_punct(")") return inner - metric_tok = c.expect_ident() - metric = metric_tok.value.lower() + metric, metric_pos = _parse_reject_metric(c) if metric not in REJECT_METRICS: raise QueryParseError( - f"can't reject on '{metric_tok.value}'; valid metrics: " + f"can't reject on '{metric}'; valid metrics: " + ", ".join(sorted(REJECT_METRICS)), - metric_tok.pos, + metric_pos, ) nxt = c.peek() @@ -614,7 +645,7 @@ def _parse_reject_cmp(c: _Cursor) -> RejectNode: # Comparison form: metric number if nxt is None or nxt.kind != "op": raise QueryParseError( - "expected a comparison (e.g. value > 100) or 'between'/'outside'", + "expected a comparison (e.g. signal.value > 100) or 'between'/'outside'", nxt.pos if nxt else c._tail_pos(), ) op = nxt.value diff --git a/query/tests/__init__.py b/query/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/query/tests/conftest.py b/query/tests/conftest.py deleted file mode 100644 index e5f867ec..00000000 --- a/query/tests/conftest.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Shared test fixtures and import-time guards for the query service tests. - -query.config.config reads DB connection settings from the environment at -*class-definition* time, and casts DATABASE_PORT to int unconditionally -(`int(os.getenv('DATABASE_PORT'))`). query_exec imports that config transitively -via the ClickHouse client, so importing the module under test would crash on a -machine without those vars. conftest is imported by pytest before any test -module, so setting harmless defaults here is enough — every ClickHouse call is -monkeypatched, no live database is touched. -""" - -import os - -os.environ.setdefault("DATABASE_HOST", "localhost") -os.environ.setdefault("DATABASE_PORT", "5432") -os.environ.setdefault("DATABASE_USER", "test") -os.environ.setdefault("DATABASE_PASSWORD", "test") -os.environ.setdefault("DATABASE_NAME", "test") diff --git a/query/tests/test_query_exec.py b/query/tests/test_query_exec.py deleted file mode 100644 index 48bb5668..00000000 --- a/query/tests/test_query_exec.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Tests for the ClickHouse SQL builder + response shaping (query_exec.py). - -The ClickHouse client is monkeypatched to record the SQL/params it receives and -return canned rows, so nothing here needs a live database. Covers LIKE-escaping -of filter literals, the in/not-in OR/AND grouping, the window-global sigma reject -path, and JSON-safe coercion of non-finite floats. -""" - -from datetime import datetime, timezone - -import pytest - -from query.service import query_exec -from query.service.query_lang import parse -from query.service.signals import utc_iso - -START = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) -END = datetime(2026, 1, 1, 0, 0, 5, tzinfo=timezone.utc) - - -def _b(sec: int) -> datetime: - return datetime(2026, 1, 1, 0, 0, sec, tzinfo=timezone.utc) - - -class _FakeResult: - def __init__(self, rows): - self.result_rows = rows - - -class _Recorder: - """Captures every (sql, params) and replays canned rows in FIFO order.""" - - def __init__(self, row_batches): - self._batches = list(row_batches) - self.calls: list[tuple[str, dict]] = [] - - def query(self, sql, parameters=None): - self.calls.append((sql, dict(parameters or {}))) - rows = self._batches.pop(0) if self._batches else [] - return _FakeResult(rows) - - -def _install(monkeypatch, *row_batches): - rec = _Recorder(row_batches) - monkeypatch.setattr(query_exec, "get_clickhouse", lambda: rec) - return rec - - -def _run(q, rec_first_batch=None): - return query_exec.run_query(q, "veh-1", START, END, "1s") - - -# --------------------------------------------------------------------------- -# Filter SQL — LIKE escaping (the bug fix) -# --------------------------------------------------------------------------- - - -def test_wildcard_translates_to_like(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name = "ecu*")')) - sql, params = rec.calls[0] - assert "name LIKE {f_name_eq_0:String}" in sql - assert params["f_name_eq_0"] == "ecu%" - - -def test_like_escapes_literal_underscore(monkeypatch): - # `ecu_acc*` must match `ecu_acc...` literally, NOT `ecuXacc...`: the `_` - # is escaped before `*` becomes `%`. - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name = "ecu_acc*")')) - _, params = rec.calls[0] - assert params["f_name_eq_0"] == r"ecu\_acc%" - - -def test_like_escapes_literal_percent_and_backslash(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse(r'count(signal).where(name = "a%b\c*")')) - _, params = rec.calls[0] - # backslash doubled, percent escaped, then `*` -> `%`. - assert params["f_name_eq_0"] == "a\\%b\\\\c%" - - -def test_not_like_for_negated_wildcard(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name != "ecu*")')) - sql, params = rec.calls[0] - assert "name NOT LIKE {f_name_ne_0:String}" in sql - assert params["f_name_ne_0"] == "ecu%" - - -def test_non_wildcard_equality_unaffected(monkeypatch): - # No `*` -> plain `=`, and underscores are NOT escaped (it's not a LIKE). - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name = "ecu_acc_pedal")')) - sql, params = rec.calls[0] - assert "name = {f_name_eq_0:String}" in sql - assert params["f_name_eq_0"] == "ecu_acc_pedal" - - -# --------------------------------------------------------------------------- -# in / not in — OR within a column, AND for none-of -# --------------------------------------------------------------------------- - - -def test_in_groups_with_or(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name in ("a", "b"))')) - sql, params = rec.calls[0] - assert "(name = {f_name_eq_0:String} OR name = {f_name_eq_1:String})" in sql - assert params["f_name_eq_0"] == "a" - assert params["f_name_eq_1"] == "b" - - -def test_not_in_groups_with_and(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse('count(signal).where(name not in ("a", "b"))')) - sql, params = rec.calls[0] - assert ( - "(name != {f_name_ne_0:String} AND name != {f_name_ne_1:String})" in sql - ) - assert params["f_name_ne_0"] == "a" - assert params["f_name_ne_1"] == "b" - - -# --------------------------------------------------------------------------- -# Aggregator / group-by SQL shape -# --------------------------------------------------------------------------- - - -def test_agg_aliases_agg_value_not_value(monkeypatch): - # `AS value` would shadow the raw `value` column a reject references. - rec = _install(monkeypatch, []) - _run(parse("avg(value)")) - sql, _ = rec.calls[0] - assert "avg(value) AS agg_value" in sql - assert " AS value" not in sql - - -def test_group_by_emits_series_alias(monkeypatch): - rec = _install(monkeypatch, []) - _run(parse("count(signal).by(name)")) - sql, _ = rec.calls[0] - assert "name AS series_name" in sql - assert "GROUP BY bucket, series_name" in sql - - -def test_explicit_every_overrides_interval(monkeypatch): - rec = _install(monkeypatch, [], []) - out = query_exec.run_query( - parse("count(signal).every(100ms)"), "veh-1", START, END, "1s" - ) - assert out["interval"] == "100ms" - - -# --------------------------------------------------------------------------- -# Reject — value path (cheap NOT) vs sigma path (window subquery) -# --------------------------------------------------------------------------- - - -def test_value_reject_uses_not_on_main_where(monkeypatch): - # value/raw_value rejects stay on the cheap NOT(...) path — no subquery. - rec = _install(monkeypatch, [], []) - query_exec.run_query( - parse("avg(value).reject(value > 100)"), "veh-1", START, END, "1s" - ) - sql, params = rec.calls[0] - assert "NOT (" in sql - assert "OVER (" not in sql - # threshold parameterized, never interpolated. - assert 100.0 in params.values() - - -def test_sigma_reject_builds_window_global_subquery(monkeypatch): - # sigma forces the window-stats subquery; stats are WINDOW-GLOBAL per series - # (PARTITION BY the group-by col), not per time bucket. - rec = _install(monkeypatch, [], []) - query_exec.run_query( - parse("last(value).by(name).reject(sigma > 3)"), - "veh-1", - START, - END, - "1s", - ) - sql, params = rec.calls[0] - assert "avg(value) OVER (PARTITION BY name) AS _mean" in sql - assert "stddevPop(value) OVER (PARTITION BY name) AS _std" in sql - # sigma expression guards a degenerate (zero-variance) group. - assert "nullIf(_std, 0)" in sql - assert "coalesce(" in sql - assert 3.0 in params.values() - - -def test_sigma_reject_without_group_has_empty_partition(monkeypatch): - rec = _install(monkeypatch, [], []) - query_exec.run_query( - parse("avg(value).reject(sigma > 2)"), "veh-1", START, END, "1s" - ) - sql, _ = rec.calls[0] - assert "avg(value) OVER () AS _mean" in sql - - -# --------------------------------------------------------------------------- -# Response shaping + JSON-safe coercion -# --------------------------------------------------------------------------- - - -def test_zero_fill_for_count(monkeypatch): - # count zero-fills absent buckets; only :00 present. - _install(monkeypatch, [(_b(0), 5)]) - out = query_exec.run_query( - parse("count(signal)"), "veh-1", START, END, "1s" - ) - by_bucket = {p["bucket"]: p["value"] for p in out["series"][0]["points"]} - assert by_bucket[utc_iso(_b(0))] == 5 - assert by_bucket[utc_iso(_b(2))] == 0 - - -def test_null_fill_for_non_count(monkeypatch): - _install(monkeypatch, [(_b(0), 10.0)]) - out = query_exec.run_query( - parse("avg(value)"), "veh-1", START, END, "1s" - ) - by_bucket = {p["bucket"]: p["value"] for p in out["series"][0]["points"]} - assert by_bucket[utc_iso(_b(0))] == 10.0 - assert by_bucket[utc_iso(_b(2))] is None - - -def test_empty_result_still_emits_one_series(monkeypatch): - _install(monkeypatch, []) - out = query_exec.run_query( - parse("avg(value)"), "veh-1", START, END, "1s" - ) - assert len(out["series"]) == 1 - - -def test_non_finite_agg_coerced_to_none(monkeypatch): - # An agg over an all-NULL bucket can come back as NaN/Inf; these must become - # None so JSONResponse(allow_nan=False) doesn't blow up. - _install(monkeypatch, [(_b(0), float("nan")), (_b(1), float("inf"))]) - out = query_exec.run_query( - parse("avg(value)"), "veh-1", START, END, "1s" - ) - by_bucket = {p["bucket"]: p["value"] for p in out["series"][0]["points"]} - assert by_bucket[utc_iso(_b(0))] is None - assert by_bucket[utc_iso(_b(1))] is None - - -def test_coerce_number_handles_decimal_like(): - from decimal import Decimal - - assert query_exec._coerce_number(Decimal("3.5")) == 3.5 - assert query_exec._coerce_number(None) is None - assert query_exec._coerce_number(float("nan")) is None - assert query_exec._coerce_number(7) == 7 - - -# --------------------------------------------------------------------------- -# Invalid interval surfaces loudly -# --------------------------------------------------------------------------- - - -def test_invalid_request_interval_raises(monkeypatch): - _install(monkeypatch, []) - with pytest.raises(ValueError): - query_exec.run_query( - parse("count(signal)"), "veh-1", START, END, "7s" - ) diff --git a/query/tests/test_query_lang.py b/query/tests/test_query_lang.py deleted file mode 100644 index 4a77038a..00000000 --- a/query/tests/test_query_lang.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Tests for the MQL parser (query_lang.py) — current method-chain grammar. - -Covers the tokenizer's tricky cases (intervals vs numbers, negatives, escaped -strings), every method (where/by/every/reject/fill), membership and negation, -the `-> name` label, reject precedence + ranges, error column positions, and the -renamed-method migration error. -""" - -import pytest - -from query.service.query_lang import ( - Predicate, - QueryParseError, - RejectBool, - RejectCmp, - RejectRange, - _tokenize, - parse, -) - - -def _err(s: str) -> QueryParseError: - with pytest.raises(QueryParseError) as ei: - parse(s) - return ei.value - - -# --------------------------------------------------------------------------- -# Tokenizer -# --------------------------------------------------------------------------- - - -def test_tokenize_interval_vs_number(): - # `100ms`/`3s` must lex as single interval tokens, not number + ident. - kinds = [(t.kind, t.value) for t in _tokenize("100ms 3 3.5 3s")] - assert kinds == [ - ("interval", "100ms"), - ("number", "3"), - ("number", "3.5"), - ("interval", "3s"), - ] - - -def test_tokenize_negative_number(): - toks = _tokenize("value > -3.5") - assert [(t.kind, t.value) for t in toks] == [ - ("ident", "value"), - ("op", ">"), - ("number", "-3.5"), - ] - - -def test_tokenize_escaped_string_strips_quotes_keeps_escape(): - # The tokenizer strips the surrounding quotes but preserves the inner - # backslash escape verbatim (the grammar defines no unescaping). - (tok,) = _tokenize(r'"ecu\"x"') - assert tok.kind == "string" - assert tok.value == r"ecu\"x" - - -def test_tokenize_two_char_ops_before_single(): - assert [t.value for t in _tokenize(">= <= != > < =")] == [ - ">=", - "<=", - "!=", - ">", - "<", - "=", - ] - - -def test_tokenize_unexpected_character_position(): - e = _err("count(signal) @") - assert "unexpected character" in str(e) - assert e.position == 14 - - -# --------------------------------------------------------------------------- -# Aggregator head -# --------------------------------------------------------------------------- - - -def test_minimal_count_signal(): - q = parse("count(signal)") - assert q.fn == "count" - assert q.field == "signal" - assert q.filters == () - assert q.group_by == () - assert q.label is None - - -def test_numeric_fn_requires_numeric_field(): - e = _err("avg(signal)") - assert "numeric field" in str(e) - - -def test_count_rejects_numeric_field(): - e = _err("count(value)") - assert "operates on rows" in str(e) - - -def test_unknown_function(): - e = _err("median(value)") - assert "unknown function" in str(e) - assert e.position == 0 - - -def test_unknown_field(): - e = _err("avg(speed)") - assert "unknown field" in str(e) - - -# --------------------------------------------------------------------------- -# .where — equality, membership, negation -# --------------------------------------------------------------------------- - - -def test_where_equality(): - q = parse('avg(value).where(name = "ecu_acc_pedal")') - assert q.filters == (Predicate(column="name", op="=", value="ecu_acc_pedal"),) - - -def test_where_not_equal(): - q = parse('avg(value).where(name != "ecu_acc_pedal")') - assert q.filters == (Predicate(column="name", op="!=", value="ecu_acc_pedal"),) - - -def test_where_in_desugars_to_eq_list(): - q = parse('avg(value).where(name in ("a", "b"))') - assert q.filters == ( - Predicate(column="name", op="=", value="a"), - Predicate(column="name", op="=", value="b"), - ) - - -def test_where_not_in_desugars_to_ne_list(): - q = parse('avg(value).where(name not in ("a", "b"))') - assert q.filters == ( - Predicate(column="name", op="!=", value="a"), - Predicate(column="name", op="!=", value="b"), - ) - - -def test_where_wildcard_value_preserved_for_executor(): - # The parser keeps `*` verbatim; the executor translates it to LIKE. - q = parse('count(signal).where(name = "ecu*")') - assert q.filters == (Predicate(column="name", op="=", value="ecu*"),) - - -def test_where_unfilterable_column(): - e = _err('avg(value).where(vehicle_id = "x")') - assert "can't filter on" in str(e) - - -def test_where_not_without_in(): - e = _err('avg(value).where(name not "a")') - assert "expected 'in' after 'not'" in str(e) - - -def test_where_requires_quoted_string(): - e = _err("avg(value).where(name = 5)") - assert "quoted string" in str(e) - - -def test_where_bad_operator(): - e = _err('avg(value).where(name > "a")') - assert "expected '=', '!=', 'in', or 'not in'" in str(e) - - -# --------------------------------------------------------------------------- -# .by -# --------------------------------------------------------------------------- - - -def test_by_single_column(): - q = parse("count(signal).by(name)") - assert q.group_by == ("name",) - - -def test_by_ungroupable_column(): - e = _err("count(signal).by(value)") - assert "can't group by" in str(e) - - -# --------------------------------------------------------------------------- -# .every -# --------------------------------------------------------------------------- - - -def test_every_valid_interval(): - q = parse("count(signal).every(100ms)") - assert q.rollup == "100ms" - - -def test_every_invalid_interval(): - # `3s` lexes as an interval but isn't an allowed rollup — proves the - # interval token survived (not a number/lex error). - e = _err("count(signal).every(3s)") - assert "invalid interval" in str(e) - - -def test_every_specified_twice(): - e = _err("count(signal).every(1s).every(10s)") - assert "more than once" in str(e) - - -# --------------------------------------------------------------------------- -# .fill -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("mode", ["gap", "last", "linear"]) -def test_fill_modes(mode): - q = parse(f"avg(value).fill({mode})") - assert q.fill == mode - - -def test_fill_invalid_mode(): - e = _err("avg(value).fill(bogus)") - assert "invalid fill mode" in str(e) - - -# --------------------------------------------------------------------------- -# .reject — comparisons, ranges, and/or precedence -# --------------------------------------------------------------------------- - - -def test_reject_simple_cmp(): - q = parse("avg(value).reject(value > 100)") - assert q.reject == RejectCmp(metric="value", op=">", threshold=100.0) - - -def test_reject_sigma_cmp(): - q = parse("last(value).reject(sigma > 3)") - assert q.reject == RejectCmp(metric="sigma", op=">", threshold=3.0) - - -def test_reject_between_is_inside(): - q = parse("avg(value).reject(value between (0, 100))") - assert q.reject == RejectRange( - metric="value", lo=0.0, hi=100.0, inside=True - ) - - -def test_reject_outside_is_not_inside(): - q = parse("avg(value).reject(raw_value outside (0, 100))") - assert q.reject == RejectRange( - metric="raw_value", lo=0.0, hi=100.0, inside=False - ) - - -def test_reject_and_binds_tighter_than_or(): - # a or b and c ==> a OR (b AND c) - q = parse( - "avg(value).reject(value > 1 or value < -1 and raw_value > 5)" - ) - assert q.reject == RejectBool( - op="or", - left=RejectCmp(metric="value", op=">", threshold=1.0), - right=RejectBool( - op="and", - left=RejectCmp(metric="value", op="<", threshold=-1.0), - right=RejectCmp(metric="raw_value", op=">", threshold=5.0), - ), - ) - - -def test_reject_parens_override_precedence(): - # (a or b) and c - q = parse( - "avg(value).reject((value > 1 or value < -1) and raw_value > 5)" - ) - assert q.reject == RejectBool( - op="and", - left=RejectBool( - op="or", - left=RejectCmp(metric="value", op=">", threshold=1.0), - right=RejectCmp(metric="value", op="<", threshold=-1.0), - ), - right=RejectCmp(metric="raw_value", op=">", threshold=5.0), - ) - - -def test_reject_sigma_range_rejected(): - e = _err("avg(value).reject(sigma between (0, 3))") - assert "sigma" in str(e).lower() - - -def test_reject_unknown_metric(): - e = _err("avg(value).reject(speed > 3)") - assert "can't reject on" in str(e) - - -def test_reject_specified_twice(): - e = _err("avg(value).reject(value > 1).reject(value < 0)") - assert "more than once" in str(e) - - -# --------------------------------------------------------------------------- -# `-> name` label -# --------------------------------------------------------------------------- - - -def test_label_assignment(): - q = parse('count(signal).where(name = "ecu*") -> ecu') - assert q.label == "ecu" - assert q.filters == (Predicate(column="name", op="=", value="ecu*"),) - - -def test_label_with_by_rejected(): - e = _err("count(signal).by(name) -> x") - assert "can't be combined with '.by'" in str(e) - - -def test_label_missing_name(): - e = _err("count(signal) ->") - assert "expected a variable name after '->'" in str(e) - - -def test_trailing_garbage_after_label(): - e = _err("count(signal) -> x y") - assert "unexpected" in str(e) - - -# --------------------------------------------------------------------------- -# Method dispatch errors -# --------------------------------------------------------------------------- - - -def test_renamed_method_migration_error(): - e = _err("count(signal).rollup(1s)") - assert "was renamed to '.every'" in str(e) - # Points at the method token, just past the leading `.`. - assert e.position == len("count(signal).") - - -def test_unknown_method(): - e = _err("count(signal).foo(1)") - assert "unknown method" in str(e) - - -def test_method_not_chained_with_dot(): - e = _err("count(signal) by(name)") - assert "chained with '.'" in str(e) - - -def test_empty_query(): - e = _err(" ") - assert "empty" in str(e) - assert e.position == 0 - - -def test_method_order_does_not_matter(): - a = parse('avg(value).where(name = "x").every(1s)') - b = parse('avg(value).every(1s).where(name = "x")') - assert a.filters == b.filters - assert a.rollup == b.rollup diff --git a/query/tests/test_query_pairs.py b/query/tests/test_query_pairs.py deleted file mode 100644 index f23e9256..00000000 --- a/query/tests/test_query_pairs.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Tests for the /query/pairs decimate + merge_asof path (service/query.py). - -The ClickHouse client is monkeypatched: query_df returns a canned DataFrame and -the (sql, params) it was called with is recorded, so nothing here needs a live -database. Covers the shared decimation SQL (vs the full-resolution branch) and -the merge_asof nearest-within-tolerance alignment that backs the XY-scatter -contract. -""" - -from datetime import datetime, timezone - -import pandas as pd - -from query.service import query as query_svc -from query.service.query import merge_signals, query_signals - - -class _Recorder: - """Captures every (sql, params) and replays one canned DataFrame.""" - - def __init__(self, df: pd.DataFrame): - self._df = df - self.calls: list[tuple[str, dict]] = [] - - def query_df(self, sql, parameters=None): - self.calls.append((sql, dict(parameters or {}))) - return self._df.copy() - - -def _install(monkeypatch, df: pd.DataFrame) -> _Recorder: - rec = _Recorder(df) - monkeypatch.setattr(query_svc, "get_clickhouse", lambda: rec) - return rec - - -def _rows(triples) -> pd.DataFrame: - return pd.DataFrame(triples, columns=["bucket_ts", "name", "value"]) - - -# --------------------------------------------------------------------------- -# Decimation SQL — used when max_points + a bounded window are given -# --------------------------------------------------------------------------- - - -def test_decimation_sql_shape(monkeypatch): - rec = _install(monkeypatch, _rows([])) - query_signals( - "veh-1", - ["a", "b"], - start="2026-01-01T00:00:00Z", - end="2026-01-01T00:00:10Z", - max_points=5, - ) - sql, params = rec.calls[0] - assert "argMin(value, produced_at)" in sql - assert "intDiv(toUnixTimestamp64Micro(produced_at), {bucket:Int64})" in sql - # 10s window / 5 points -> 2s buckets -> 2_000_000 micros. - assert params["bucket"] == 2_000_000 - assert isinstance(params["start"], datetime) - assert params["start"].tzinfo is None # naive UTC for DateTime64 binding - - -def test_full_resolution_sql_when_no_max_points(monkeypatch): - rec = _install(monkeypatch, _rows([])) - query_signals( - "veh-1", - ["a"], - start="2026-01-01T00:00:00Z", - end="2026-01-01T00:00:10Z", - max_points=None, - ) - sql, params = rec.calls[0] - assert "ORDER BY produced_at ASC" in sql - assert "argMin" not in sql - assert "bucket" not in params - - -# --------------------------------------------------------------------------- -# Pivot -> per-signal DataFrames -# --------------------------------------------------------------------------- - - -def test_query_signals_returns_one_frame_per_present_signal(monkeypatch): - df = _rows( - [ - (datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "a", 1.0), - (datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "b", 10.0), - (datetime(2026, 1, 1, 0, 0, 1, tzinfo=timezone.utc), "a", 2.0), - ] - ) - _install(monkeypatch, df) - frames = query_signals( - "veh-1", ["a", "b"], start="2026-01-01T00:00:00Z", - end="2026-01-01T00:00:10Z", max_points=100, - ) - assert len(frames) == 2 - assert list(frames[0].columns) == ["produced_at", "a"] - assert frames[0]["a"].tolist() == [1.0, 2.0] - assert frames[1]["b"].tolist() == [10.0] - - -# --------------------------------------------------------------------------- -# merge_asof — nearest within tolerance (the XY-scatter alignment contract) -# --------------------------------------------------------------------------- - - -def _series(times_ms, values, col): - base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - return pd.DataFrame( - { - "produced_at": [base + pd.Timedelta(milliseconds=t) for t in times_ms], - col: values, - } - ) - - -def test_merge_asof_aligns_nearest_within_tolerance(): - # anchor (smallest) is `a` at t=0,200ms. `b` at t=20,180ms is within 50ms of - # each anchor row, so each anchor row pulls the nearest b value. - a = _series([0, 200], [1.0, 2.0], "a") - b = _series([20, 180], [10.0, 20.0], "b") - merged, meta = merge_signals(a, b, strategy="smallest", tolerance=50) - assert merged["produced_at"].tolist() == a["produced_at"].tolist() - assert merged["a"].tolist() == [1.0, 2.0] - assert merged["b"].tolist() == [10.0, 20.0] - assert meta.num_rows == 2 - - -def test_merge_asof_drops_match_outside_tolerance(): - # b's only sample is 500ms from the anchor row, beyond the 50ms tolerance, - # so the merged b value is NaN -> coerced to 0 only under fill; with - # fill="none" it stays NaN. - a = _series([0], [1.0], "a") - b = _series([500], [10.0], "b") - merged, _ = merge_signals(a, b, strategy="smallest", tolerance=50, fill="none") - assert merged["a"].tolist() == [1.0] - assert pd.isna(merged["b"].iloc[0]) diff --git a/rigby/pyproject.toml b/rigby/pyproject.toml index 40e8b88a..c6757f76 100644 --- a/rigby/pyproject.toml +++ b/rigby/pyproject.toml @@ -12,17 +12,7 @@ numpy = "^1.26.4" [tool.poetry.scripts] rigby = "rigby.main:main" -test = "tests.test:main" - -[tool.poetry.group.dev.dependencies] -pytest = "^8.1.1" -pytest-cov = "^5.0.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - -[tool.coverage.run] -source = ["rigby"] -omit = ["tests/*"] -branch = true \ No newline at end of file diff --git a/rigby/tests/__init__.py b/rigby/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/rigby/tests/nodes/gr24/bcm_test.py b/rigby/tests/nodes/gr24/bcm_test.py deleted file mode 100644 index ea76da13..00000000 --- a/rigby/tests/nodes/gr24/bcm_test.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -from rigby.nodes.gr24.bcm import BCM - -def test_generate(): - bcm = BCM() - bcm.generate() - for i in range(3): - assert bcm.imu_accel[i] >= -32768 and bcm.imu_accel[i] <= 32767 - assert bcm.imu_gyro[i] >= -32768 and bcm.imu_gyro[i] <= 32767 - assert bcm.imu_mag[i] >= -32768 and bcm.imu_mag[i] <= 32767 - -def test_test_generate(): - bcm = BCM() - bcm.test_generate() - assert bcm.imu_accel == [-23952, 32199, 0] - assert bcm.imu_gyro == [32199, 963, -19249] - assert bcm.imu_mag == [10, 0, -19] - -def test_to_bytes(): - bcm = BCM() - bcm.test_generate() - assert bcm.to_bytes() == bytearray(b'\x80\x002\x1e\x00\x00\x00\x00\xa2p}\xc7\x00\x00\x00\x00}\xc7\x03\xc3\xb4\xcf\x00\x00\xff\x00\x81)d\x17\x13\xc7\x00\xff\xc7\x02d+\\H\x80\x002\x1e\x00\x00\x00\x00\xa2p}\xc7\x00\x00\x00\x00}\xc7\x03\xc3\xb4\xcf\x00\x00\xff\x00\x81)d\x17\x13\xc7\x00\xff\xc7\x02d+\\H\x80\x002\x1e\x00\x00\x00\x00\xa2p}\xc7\x00\x00\x00\x00}\xc7\x03\xc3\xb4\xcf\x00\x00\xff\x00\x81)d\x17\x13\xc7\x00\xff\xc7\x02d+\\H\x80\x002\x1e\x00\x00\x00\x00\xa2p}\xc7\x00\x00\x00\x00}\xc7\x03\xc3\xb4\xcf\x00\x00\xff\x00\x81)d\x17\x13\xc7\x00\xff\xc7\x02d+\\H\xa2p}\xc7\x00\x00\x00\x00}\xc7\x03\xc3\xb4\xcf\x00\x00\x00\n\x00\x00\xff\xed\x00\x00') \ No newline at end of file diff --git a/rigby/tests/nodes/gr24/gps_test.py b/rigby/tests/nodes/gr24/gps_test.py deleted file mode 100644 index c5679f6e..00000000 --- a/rigby/tests/nodes/gr24/gps_test.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from rigby.nodes.gr24.gps import GPS - -def test_generate(): - gps = GPS() - gps.generate() - assert gps.latitude >= -90 and gps.latitude <= 90 - assert gps.longitude >= -180 and gps.longitude <= 180 - -def test_to_bytes(): - gps = GPS() - gps.latitude = 34.414718 - gps.longitude = -119.841912 - assert gps.to_bytes() == bytearray(b'B\t\xa8\xac\xc2\xef\xaf\x0f') \ No newline at end of file diff --git a/rigby/tests/nodes/gr24/pedal_test.py b/rigby/tests/nodes/gr24/pedal_test.py deleted file mode 100644 index 9451a343..00000000 --- a/rigby/tests/nodes/gr24/pedal_test.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest -from rigby.nodes.gr24.pedal import Pedal - -def test_generate(): - pedal = Pedal() - pedal.generate() - assert pedal.APPS1 >= 44256 and pedal.APPS1 <= 50100 - assert pedal.APPS2 >= 38750 and pedal.APPS2 <= 41810 - -def test_test_generate(): - pedal = Pedal() - pedal.test_generate() - assert pedal.APPS1 == 44956 - assert pedal.APPS2 == 38950 - assert pedal.millis == 12838 - -def test_to_bytes(): - pedal = Pedal() - pedal.APPS1 = 100 - pedal.APPS2 = 50 - assert pedal.to_bytes() == bytearray(b'\x00d\x002\x00\x00\x00\x00') \ No newline at end of file diff --git a/rigby/tests/nodes/gr24/wheel_test.py b/rigby/tests/nodes/gr24/wheel_test.py deleted file mode 100644 index 0a5d53b1..00000000 --- a/rigby/tests/nodes/gr24/wheel_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -from rigby.nodes.gr24.wheel import Wheel - -def test_generate(): - wheel = Wheel() - wheel.generate() - assert wheel.suspension >= 0 and wheel.suspension <= 255 - assert wheel.wheel_speed >= 0 and wheel.wheel_speed <= 100 - assert wheel.tire_pressure >= 20 and wheel.tire_pressure <= 40 - for i in range(3): - assert wheel.imu_accel[i] >= -32768 and wheel.imu_accel[i] <= 32767 - assert wheel.imu_gyro[i] >= -32768 and wheel.imu_gyro[i] <= 32767 - for i in range(8): - assert wheel.brake_temp[i] >= 0 and wheel.brake_temp[i] <= 255 - assert wheel.tire_temp[i] >= 0 and wheel.tire_temp[i] <= 255 - -def test_test_generate(): - wheel = Wheel() - wheel.test_generate() - assert wheel.suspension == 128 - assert wheel.wheel_speed == 50 - assert wheel.tire_pressure == 30 - assert wheel.imu_accel == [-23952, 32199, 0] - assert wheel.imu_gyro == [32199, 963, -19249] - assert wheel.brake_temp == [255, 0, 129, 41, 100, 23, 19, 199] - assert wheel.tire_temp == [0, 255, 199, 2, 100, 43, 92, 72] - -def test_to_bytes(): - wheel = Wheel() - wheel.suspension = 128 - wheel.wheel_speed = 50 - wheel.tire_pressure = 30 - wheel.imu_accel = [-23952, 32199, 0] - wheel.imu_gyro = [32199, 963, -19249] - wheel.brake_temp = [255, 0, 129, 41, 100, 23, 19, 199] - wheel.tire_temp = [0, 255, 199, 2, 100, 43, 92, 72] - assert wheel.to_bytes() == "10000000000000000011001000011110000000000000000000000000000000001010001001110000011111011100011100000000000000000000000000000000011111011100011100000011110000111011010011001111000000000000000011111111000000001000000100101001011001000001011100010011110001110000000011111111110001110000001001100100001010110101110001001000" \ No newline at end of file diff --git a/rigby/tests/test.py b/rigby/tests/test.py deleted file mode 100644 index 53d5f09d..00000000 --- a/rigby/tests/test.py +++ /dev/null @@ -1,17 +0,0 @@ -import subprocess - -def main(): - """Run the test command transparently (as if it was in the same process). - - If an error occurs, exit with the corresponding return code. - Prints all outputs to stdout. - """ - command = "pytest --debug --cov=rigby --cov-report=term-missing --cov-report=html --cov-report=lcov --cov-config=pyproject.toml --full-trace -v -s".split() - result = subprocess.run(command, capture_output=True) - print(result.stdout.decode('utf8'), end='') - print(result.stderr.decode('utf8'), end='') - if result.returncode != 0: - exit(code=result.returncode) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/rigby/tests/utils/binary_test.py b/rigby/tests/utils/binary_test.py deleted file mode 100644 index ed687620..00000000 --- a/rigby/tests/utils/binary_test.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest -from rigby.utils.binary import BinFactory - -def test_fill_bytes(): - assert BinFactory.fill_bytes(1) == "00000000" - assert BinFactory.fill_bytes(2) == "0000000000000000" - assert BinFactory.fill_bytes(3) == "000000000000000000000000" - assert BinFactory.fill_bytes(4) == "00000000000000000000000000000000" - -def test_int_to_bin(): - assert BinFactory.int_to_bin(0, 2) == "0000000000000000" - assert BinFactory.int_to_bin(1, 2) == "0000000000000001" - assert BinFactory.int_to_bin(-1, 2) == "1111111111111111" - assert BinFactory.int_to_bin(32767, 2) == "0111111111111111" - assert BinFactory.int_to_bin(-32768, 2) == "1000000000000000" - with pytest.raises(ValueError): - BinFactory.int_to_bin(2**16, 2) - with pytest.raises(ValueError): - BinFactory.int_to_bin(-2**16-1, 2) - -def test_uint_to_bin(): - assert BinFactory.uint_to_bin(0, 2) == "0000000000000000" - assert BinFactory.uint_to_bin(1, 2) == "0000000000000001" - assert BinFactory.uint_to_bin(32767, 2) == "0111111111111111" - assert BinFactory.uint_to_bin(32768, 2) == "1000000000000000" - assert BinFactory.uint_to_bin(65535, 2) == "1111111111111111" - with pytest.raises(ValueError): - BinFactory.uint_to_bin(-1, 2) - with pytest.raises(ValueError): - BinFactory.uint_to_bin(65536, 2) - -def test_float32_to_bin(): - assert BinFactory.float32_to_bin(0.0) == "00000000000000000000000000000000" - assert BinFactory.float32_to_bin(1.0) == "00111111100000000000000000000000" - assert BinFactory.float32_to_bin(-1.0) == "10111111100000000000000000000000" - assert BinFactory.float32_to_bin(0.5) == "00111111000000000000000000000000" - assert BinFactory.float32_to_bin(-0.5) == "10111111000000000000000000000000" - assert BinFactory.float32_to_bin(0.1) == "00111101110011001100110011001101" - assert BinFactory.float32_to_bin(-0.1) == "10111101110011001100110011001101" - -def test_bin_to_byte_array(): - assert BinFactory.bin_to_byte_array("0000000000000000") == bytearray(b'\x00\x00') - assert BinFactory.bin_to_byte_array("0000000000000001") == bytearray(b'\x00\x01') - assert BinFactory.bin_to_byte_array("1111111111111111") == bytearray(b'\xff\xff') - assert BinFactory.bin_to_byte_array("0111111111111111") == bytearray(b'\x7f\xff') - assert BinFactory.bin_to_byte_array("1000000000000000") == bytearray(b'\x80\x00') - assert BinFactory.bin_to_byte_array("1000000000000001") == bytearray(b'\x80\x01') - assert BinFactory.bin_to_byte_array("1000000000000010") == bytearray(b'\x80\x02') - assert BinFactory.bin_to_byte_array("1000000000000011") == bytearray(b'\x80\x03') - assert BinFactory.bin_to_byte_array("1000000000000100") == bytearray(b'\x80\x04') - assert BinFactory.bin_to_byte_array("1000000000000101") == bytearray(b'\x80\x05') - assert BinFactory.bin_to_byte_array("1000000000000110") == bytearray(b'\x80\x06') - assert BinFactory.bin_to_byte_array("1000000000000111") == bytearray(b'\x80\x07') - assert BinFactory.bin_to_byte_array("1000000000001000") == bytearray(b'\x80\x08') - assert BinFactory.bin_to_byte_array("1000000000001001") == bytearray(b'\x80\t') - assert BinFactory.bin_to_byte_array("1000000000001010") == bytearray(b'\x80\n') - assert BinFactory.bin_to_byte_array("1000000000001011") == bytearray(b'\x80\x0b') - assert BinFactory.bin_to_byte_array("1000000000001100") == bytearray(b'\x80\x0c') - assert BinFactory.bin_to_byte_array("1000000000001101") == bytearray(b'\x80\r') - assert BinFactory.bin_to_byte_array("1000000000001110") == bytearray(b'\x80\x0e') - assert BinFactory.bin_to_byte_array("1000000000001111") == bytearray(b'\x80\x0f') - assert BinFactory.bin_to_byte_array("1000000000010000") == bytearray(b'\x80\x10') diff --git a/rigby/tests/utils/generator_test.py b/rigby/tests/utils/generator_test.py deleted file mode 100644 index f2bb1dda..00000000 --- a/rigby/tests/utils/generator_test.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -from rigby.utils.generator import Valgen - -def test_gen_rand(): - assert Valgen.gen_rand(0, 0) == 0 - assert Valgen.gen_rand(0, 1) in [0, 1] - assert Valgen.gen_rand(1, 1) == 1 - assert Valgen.gen_rand(0, 2) in [0, 1, 2] - assert Valgen.gen_rand(1, 2) in [1, 2] - assert Valgen.gen_rand(2, 2) == 2 - assert Valgen.gen_rand(-1, 1) in [-1, 0, 1] - -def test_smart_rand(): - assert Valgen.smart_rand(0, 0, 0, 0) == 0 - assert Valgen.smart_rand(0, 1, 0, 0) in [0, 1] - assert Valgen.smart_rand(1, 1, 1, 0) == 1 - assert Valgen.smart_rand(0, 2, 0, 0) in [0, 1, 2] - assert Valgen.smart_rand(1, 2, 1, 0) in [1, 2] - assert Valgen.smart_rand(2, 2, 2, 0) == 2 - assert Valgen.smart_rand(-1, 1, 0, 0) in [-1, 0, 1] - assert Valgen.smart_rand(0, 0, 0, 1) in [0, 1] - assert Valgen.smart_rand(0, 1, 0, 1) in [0, 1, 2] - assert Valgen.smart_rand(1, 1, 1, 1) in [0, 1, 2] - assert Valgen.smart_rand(0, 2, 0, 1) in [0, 1, 2, 3] - assert Valgen.smart_rand(1, 2, 1, 1) in [0, 1, 2, 3] - assert Valgen.smart_rand(2, 2, 20, 1) in [1, 2, 3] - assert Valgen.smart_rand(-1, 1, -20, 1) in [-1, 0, 1, 2] \ No newline at end of file From dc817ff4783aa791ce5aac10737e4e205e45fd76 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 00:50:37 -0400 Subject: [PATCH 02/11] fix(signals): match Modify button height to other chips (h-7) --- dashboard/src/components/signals/QueryBuilder.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 30329fb9..a16ab041 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -402,7 +402,9 @@ function ModifyMenu({ - - - setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commit(); - } else if (e.key === "Escape") { - setDraft(value ?? ""); - setOpen(false); - } - }} - placeholder="variable name (e.g. ecu)" - className="h-8 font-mono text-xs" - /> - - - ); -} - // Outlier rejection. Two toggles combine into a RejectNode (OR'd): statistical // outliers (`sigma > N`) and hard limits (`value outside (min, max)`, or a // single comparison). UI state is read back from the node, not held in parallel, diff --git a/dashboard/src/lib/query.ts b/dashboard/src/lib/query.ts index 4b96fc63..29981c26 100644 --- a/dashboard/src/lib/query.ts +++ b/dashboard/src/lib/query.ts @@ -139,7 +139,7 @@ export function serializeQuery(q: Query): string { } if (q.rollup) { - out += `.every(${q.rollup})`; + out += `.rollup(${q.rollup})`; } if (q.fill) { @@ -276,8 +276,7 @@ const ROLLUP_SET = new Set(ROLLUP_INTERVALS); const FILL_SET = new Set(FILL_MODES); const REJECT_METRIC_SET = new Set(REJECT_METRICS); const COMPARISON_OPS = new Set([">", ">=", "<", "<=", "=", "!="]); -const RENAMED_METHODS: Record = { rollup: "every" }; -const METHODS = new Set(["where", "by", "every", "reject", "fill"]); +const METHODS = new Set(["where", "by", "rollup", "reject", "fill"]); /** Parse an MQL string into a validated Query AST. Never throws — returns a * result object with a {message, position} error on failure. */ @@ -308,8 +307,6 @@ export function parseQuery(input: string): ParseResult { c.advance(); const methodTok = c.expectIdent(); const method = methodTok.value.toLowerCase(); - if (RENAMED_METHODS[method]) - throw new MqlParseError(`'.${method}' was renamed to '.${RENAMED_METHODS[method]}'`, methodTok.pos); if (!METHODS.has(method)) throw new MqlParseError( `unknown method '.${methodTok.value}'; expected one of ` + @@ -319,8 +316,8 @@ export function parseQuery(input: string): ParseResult { c.expectPunct("("); if (method === "where") filters.push(...parseWhereArgs(c)); else if (method === "by") groupBy.push(...parseByArgs(c)); - else if (method === "every") { - if (rollup) throw new MqlParseError("'.every' specified more than once", methodTok.pos); + else if (method === "rollup") { + if (rollup) throw new MqlParseError("'.rollup' specified more than once", methodTok.pos); rollup = parseEveryArgs(c); } else if (method === "reject") { if (reject) throw new MqlParseError("'.reject' specified more than once", methodTok.pos); diff --git a/query/query/service/query_lang.py b/query/query/service/query_lang.py index b29c614d..4dbd6484 100644 --- a/query/query/service/query_lang.py +++ b/query/query/service/query_lang.py @@ -1,21 +1,21 @@ """Tiny query language for the signals chart. -Grammar (v0.3 — method-chain): - () ( '.' '(' ')' )* ( '->' )? - methods: .where() .by(,...) .every() +Grammar (v0.4 — method-chain, dotted fields): + () ( '.' '(' ')' )* ( '->' )? + methods: .where() .by(,...) .rollup() .reject() .fill() A trailing `-> name` names the result series (and, on the frontend, exposes it as a referenceable variable). Examples: - count(signal) - count(signal).by(name) - avg(value).where(name = "ecu_acc_pedal") - count(signal).where(name = "ecu*") -> ecu - count(signal).where(name != "ecu*") -> other - avg(value).where(name not in ("a", "b")) - last(value).where(name in ("a", "b")).reject(sigma > 3).every(100ms) - avg(value).reject(value outside (0, 100)).fill(last) + count(signal.name) + count(signal.name).by(name) + avg(signal.value).where(name = "ecu_acc_pedal") + count(signal.name).where(name = "ecu*") -> ecu + count(signal.name).where(name != "ecu*") -> other + avg(signal.value).where(name not in ("a", "b")) + last(signal.value).where(name in ("a", "b")).reject(sigma > 3).rollup(100ms) + avg(signal.value).reject(signal.value outside (0, 100)).fill(last) This is intentionally small. We want it to feel like Datadog's metric query syntax (one aggregator + filters + group-by + automatic time @@ -194,10 +194,7 @@ def _tokenize(s: str) -> list[Token]: # Parser (recursive descent over a token cursor) # --------------------------------------------------------------------------- -_METHODS = {"where", "by", "every", "reject", "fill"} - -# Renamed methods, flagged with a migration error instead of "unknown method". -_RENAMED_METHODS = {"rollup": "every"} +_METHODS = {"where", "by", "rollup", "reject", "fill"} class _Cursor: @@ -258,7 +255,7 @@ def parse(s: str) -> Query: filters: list[Predicate] = [] group_by: list[str] = [] rollup: str | None = None - every_seen_pos: int | None = None + rollup_seen_pos: int | None = None reject: RejectNode | None = None reject_seen_pos: int | None = None fill: str | None = None @@ -281,11 +278,6 @@ def parse(s: str) -> Query: method_tok = c.expect_ident() method = method_tok.value.lower() - if method in _RENAMED_METHODS: - raise QueryParseError( - f"'.{method}' was renamed to '.{_RENAMED_METHODS[method]}'", - method_tok.pos, - ) if method not in _METHODS: raise QueryParseError( f"unknown method '.{method_tok.value}'; expected one of " @@ -298,14 +290,14 @@ def parse(s: str) -> Query: filters.extend(_parse_where_args(c)) elif method == "by": group_by.extend(_parse_by_args(c)) - elif method == "every": - if every_seen_pos is not None: + elif method == "rollup": + if rollup_seen_pos is not None: raise QueryParseError( - "'.every' specified more than once", + "'.rollup' specified more than once", method_tok.pos, ) - rollup = _parse_every_args(c) - every_seen_pos = method_tok.pos + rollup = _parse_rollup_args(c) + rollup_seen_pos = method_tok.pos elif method == "reject": if reject_seen_pos is not None: raise QueryParseError( @@ -527,7 +519,7 @@ def _parse_by_args(c: _Cursor) -> list[str]: return cols -def _parse_every_args(c: _Cursor) -> str: +def _parse_rollup_args(c: _Cursor) -> str: """Parse a single interval literal (e.g. `10s`, `1m`, `1h`).""" iv_tok = c.peek() if iv_tok is None or iv_tok.kind != "interval": From 61f33c185e2394e60eb027bdbc3d2e0ae817447e Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:02:52 -0400 Subject: [PATCH 05/11] feat(signals): Datadog-style query row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the chip row from the sentence form ("Show of where …") to Datadog's left-to-right layout: [Signal] from [filter chips] [+ filter] | avg of signal.value [optional modifiers: by name / rollup / reject / fill] Σ - Source pill ("Signal") anchors the left edge. - Filters cluster behind a `from` keyword, mirroring Datadog's `from $service x` tag list. - Vertical hairline separates filters from the aggregator/field block. - Modifier chips render inline only when set; the function menu (Σ) sits flush-right. - "+ Modify" button becomes a Σ icon button, matching Datadog's "Apply a function" affordance. - Drops the now-unused Clause helper. The "Name series" input above the row stays put — Datadog doesn't need an equivalent because its metric names are unique by construction, but Mapache derived expressions need a referenceable variable name. --- .../src/components/signals/QueryBuilder.tsx | 136 +++++++++--------- 1 file changed, 71 insertions(+), 65 deletions(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 2e5e971f..648cdb97 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -26,7 +26,7 @@ import { import { cn } from "@/lib/utils"; import { useTextMirror } from "@/lib/useTextMirror"; import Fuse from "fuse.js"; -import { ChevronDown, Plus, X } from "lucide-react"; +import { ChevronDown, Plus, Sigma, X } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; /** Per-series summary of the raw samples a `.reject(...)` clause cut before @@ -192,51 +192,55 @@ export function QueryBuilder({
) : null} - {/* Reads as a sentence: Show of where … */} + {/* Datadog-style query row: source pill on the left, filters in the + middle, aggregator/field/breakout in the middle-right, function + menu (Σ) on the far right. Reads left-to-right as a single line + rather than the prior "Show of where …" sentence. */}
- - ({ - value: a.value, - label: a.label, - }))} - onSelect={(v) => setFn(v as Aggregator)} - /> - of - ({ value: f, label: f }))} - onSelect={(v) => setField(v as FieldName)} - disabled={fieldFixed} - /> - + Signal - - {value.filters.length === 0 ? ( - all signals - ) : ( - value.filters.map((pred, i) => { - // Same-column filters combine: matches union ("or"), negations - // intersect ("and"). Show the connector so it doesn't read ambiguously. - const prev = i > 0 ? value.filters[i - 1] : null; - const sameColAsPrev = prev !== null && prev.column === pred.column; - const connector = pred.op === "!=" ? "and" : "or"; - return ( - - {sameColAsPrev ? {connector} : null} - updateFilter(i, next)} - onRemove={() => removeFilter(i)} - signalNames={signalNames} - /> - - ); - }) - )} - - + from + {value.filters.length === 0 ? ( + all signals + ) : ( + value.filters.map((pred, i) => { + // Same-column filters combine: matches union ("or"), negations + // intersect ("and"). Show the connector so it doesn't read ambiguously. + const prev = i > 0 ? value.filters[i - 1] : null; + const sameColAsPrev = prev !== null && prev.column === pred.column; + const connector = pred.op === "!=" ? "and" : "or"; + return ( + + {sameColAsPrev ? {connector} : null} + updateFilter(i, next)} + onRemove={() => removeFilter(i)} + signalNames={signalNames} + /> + + ); + }) + )} + + + + + ({ + value: a.value, + label: a.label, + }))} + onSelect={(v) => setFn(v as Aggregator)} + /> + of + ({ value: f, label: f }))} + onSelect={(v) => setField(v as FieldName)} + disabled={fieldFixed} + /> {activeModifiers.breakout ? ( setBreakout(false)}> @@ -266,7 +270,9 @@ export function QueryBuilder({ ) : null} - +
+ +
@@ -312,23 +318,6 @@ export function QueryBuilder({ // Sentence scaffolding // --------------------------------------------------------------------------- -/** A clause is a keyword lead-in ("where", "grouped by", ...) followed by its - * chips, kept together so they wrap as a unit and read as one phrase. */ -function Clause({ - keyword, - children, -}: { - keyword: string; - children: ReactNode; -}) { - return ( - - {keyword} - {children} - - ); -} - /** Clause + an X to remove the whole modifier. The X returns the * corresponding entry to the Modify menu. */ function RemovableClause({ @@ -405,12 +394,12 @@ function ModifyMenu({ @@ -456,6 +445,23 @@ function Connector({ children }: { children: ReactNode }) { ); } +/** Static "data source" pill on the left edge of a query row. The Mapache + * signals page only has one source (signal rows from ClickHouse), so + * this is decorative — it gives the row a Datadog-style anchor point. */ +function SourcePill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Subtle vertical hairline separating logical sections of the row + * (filters vs aggregator vs functions). */ +function Divider() { + return ; +} + /** Muted placeholder shown when a clause has no chips yet. */ function Hint({ children }: { children: ReactNode }) { return ( From 2407c7162827ce3d2d84f3a5b1d124db357b3855 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:04:06 -0400 Subject: [PATCH 06/11] fix(signals): inline Modify button instead of right-flush --- dashboard/src/components/signals/QueryBuilder.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 648cdb97..0756a3a3 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -270,9 +270,7 @@ export function QueryBuilder({ ) : null} -
- -
+
From ca282e11c15175f53b972959e74613b307c45611 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:04:56 -0400 Subject: [PATCH 07/11] fix(signals): drop redundant "name is" from filter chip --- dashboard/src/components/signals/QueryBuilder.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 0756a3a3..689639a3 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -906,12 +906,11 @@ function FilterChip({ type="button" className="inline-flex items-center gap-1.5 rounded-sm hover:text-primary" > - {value.column} - - {value.op === "!=" ? "is not" : "is"} - + {value.op === "!=" ? ( + not + ) : null} {filled ? ( - {value.value} + {value.value} ) : ( choose… )} From 836e57a195f696f303698227236929e48c140d04 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:16:12 -0400 Subject: [PATCH 08/11] =?UTF-8?q?fix(signals):=20rename=20.reject=E2=86=92?= =?UTF-8?q?.filter;=20signal-name=20button=20=E2=86=92=20"+=20signal"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing renames; internal Reject* type names stay the same since they describe the AST shape (sigma comparison, value range, boolean tree) regardless of what the keyword is called. - MQL grammar: `.reject(...)` becomes `.filter(...)` in both parsers and the serializer. Error messages updated to match. No migration stub. - UI: the `reject` keyword in the inline clause and the Modify menu becomes `filter`. - The existing `+ filter` button on the signal-name chip cluster now reads `+ signal`, since `filter` is now claimed by the outlier- rejection clause. The chip cluster itself is what selects signals by name, so `signal` is the more direct label. Docstring examples updated to the new keywords + the dotted-field grammar that landed earlier. --- dashboard/src/components/signals/QueryBuilder.tsx | 6 +++--- dashboard/src/lib/query.ts | 8 ++++---- query/query/service/query_lang.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 689639a3..41c69586 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -222,7 +222,7 @@ export function QueryBuilder({ ); }) )} - + @@ -255,7 +255,7 @@ export function QueryBuilder({ ) : null} {activeModifiers.reject ? ( - setReject(undefined)}> + setReject(undefined)}> (ROLLUP_INTERVALS); const FILL_SET = new Set(FILL_MODES); const REJECT_METRIC_SET = new Set(REJECT_METRICS); const COMPARISON_OPS = new Set([">", ">=", "<", "<=", "=", "!="]); -const METHODS = new Set(["where", "by", "rollup", "reject", "fill"]); +const METHODS = new Set(["where", "by", "rollup", "filter", "fill"]); /** Parse an MQL string into a validated Query AST. Never throws — returns a * result object with a {message, position} error on failure. */ @@ -319,8 +319,8 @@ export function parseQuery(input: string): ParseResult { else if (method === "rollup") { if (rollup) throw new MqlParseError("'.rollup' specified more than once", methodTok.pos); rollup = parseEveryArgs(c); - } else if (method === "reject") { - if (reject) throw new MqlParseError("'.reject' specified more than once", methodTok.pos); + } else if (method === "filter") { + if (reject) throw new MqlParseError("'.filter' specified more than once", methodTok.pos); reject = parseRejectOr(c); } else if (method === "fill") { if (fill) throw new MqlParseError("'.fill' specified more than once", methodTok.pos); diff --git a/query/query/service/query_lang.py b/query/query/service/query_lang.py index 4dbd6484..7c4ed10b 100644 --- a/query/query/service/query_lang.py +++ b/query/query/service/query_lang.py @@ -3,7 +3,7 @@ Grammar (v0.4 — method-chain, dotted fields): () ( '.' '(' ')' )* ( '->' )? methods: .where() .by(,...) .rollup() - .reject() .fill() + .filter() .fill() A trailing `-> name` names the result series (and, on the frontend, exposes it as a referenceable variable). @@ -14,8 +14,8 @@ count(signal.name).where(name = "ecu*") -> ecu count(signal.name).where(name != "ecu*") -> other avg(signal.value).where(name not in ("a", "b")) - last(signal.value).where(name in ("a", "b")).reject(sigma > 3).rollup(100ms) - avg(signal.value).reject(signal.value outside (0, 100)).fill(last) + last(signal.value).where(name in ("a", "b")).filter(sigma > 3).rollup(100ms) + avg(signal.value).filter(signal.value outside (0, 100)).fill(last) This is intentionally small. We want it to feel like Datadog's metric query syntax (one aggregator + filters + group-by + automatic time @@ -194,7 +194,7 @@ def _tokenize(s: str) -> list[Token]: # Parser (recursive descent over a token cursor) # --------------------------------------------------------------------------- -_METHODS = {"where", "by", "rollup", "reject", "fill"} +_METHODS = {"where", "by", "rollup", "filter", "fill"} class _Cursor: @@ -298,11 +298,11 @@ def parse(s: str) -> Query: ) rollup = _parse_rollup_args(c) rollup_seen_pos = method_tok.pos - elif method == "reject": + elif method == "filter": if reject_seen_pos is not None: raise QueryParseError( - "'.reject' specified more than once; combine conditions " - "with 'and'/'or' inside one .reject(...)", + "'.filter' specified more than once; combine conditions " + "with 'and'/'or' inside one .filter(...)", method_tok.pos, ) reject = _parse_reject_args(c) From 4ca8c4f404f03330a1a856a41404e54ce1ba44ee Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:19:29 -0400 Subject: [PATCH 09/11] =?UTF-8?q?fix(signals):=20"+=20Modifier"=20text=20b?= =?UTF-8?q?utton=20instead=20of=20=CE=A3=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/components/signals/QueryBuilder.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx index 41c69586..9f5e66d5 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -26,7 +26,7 @@ import { import { cn } from "@/lib/utils"; import { useTextMirror } from "@/lib/useTextMirror"; import Fuse from "fuse.js"; -import { ChevronDown, Plus, Sigma, X } from "lucide-react"; +import { ChevronDown, Plus, X } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; /** Per-series summary of the raw samples a `.reject(...)` clause cut before @@ -392,12 +392,12 @@ function ModifyMenu({ From 8e89428f9cfc4ae5c463bcd26f9aa881dab05dfb Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 01:38:00 -0400 Subject: [PATCH 10/11] feat(signals): kebab menu in header, chart-type moves above canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget header used to carry 4-5 icon buttons crammed in the top-right (chart-type, optional map toggle, optional export, hide, delete) with mixed semantics — chart content vs widget operations competing for the same corner. - Widget-level operations collapse into a single MoreVertical kebab: Export · Show/Hide map · Show/Hide chart · ── · Delete (destructive, styled accordingly). - ChartTypeSelect ("Bar ▾") drops out of the header and lands at the top of CardContent, just above the canvas. Conceptually it's a chart-content choice (what to draw), not a widget operation, so it belongs with the chart-content controls. - Header now reads "title / metadata · ⋮" — Datadog-shaped. --- .../src/components/signals/SignalWidget.tsx | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index e3769ce4..d57a9464 100644 --- a/dashboard/src/components/signals/SignalWidget.tsx +++ b/dashboard/src/components/signals/SignalWidget.tsx @@ -48,6 +48,13 @@ import type { Lap } from "@/models/session"; import type { ECharts } from "echarts/core"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { MAPBOX_ACCESS_TOKEN } from "@/consts/config"; import { formatMetric } from "@/lib/format"; import { @@ -71,6 +78,7 @@ import { Hand, Loader2, Map as MapIcon, + MoreVertical, MousePointer, Plus, Trash2, @@ -818,54 +826,51 @@ export function SignalWidget({
)}
-
- - {canShowMap && ( - - )} - {hasData && ( + {/* Widget-level operations collapse into a single kebab so the + header reads as "title, menu" rather than an icon parade. + Chart-type lives next to the chart canvas now (it's a + chart-content choice, not a widget operation). */} + + - )} - - -
+ {canShowMap && ( + setMapEnabled((v) => !v)}> + + {mapEnabled ? "Hide map" : "Show map"} + + )} + + {hidden ? ( + + ) : ( + + )} + {hidden ? "Show chart" : "Hide chart"} + + + + + Delete widget + + + {!hidden && (
@@ -896,6 +901,12 @@ export function SignalWidget({ {!hidden && ( + {/* Chart-type lives here (not in the widget header) — it picks + what the chart canvas renders, so it belongs with the + chart-content controls. */} +
+ +
{path === "timeseries" && onInteractionModeChange && ( // Left-drag mode sits just above the chart so it's a short hop to // the gesture; "select" brushes a timeframe, "pan" slides the zoom. From 01782b6905d340b0b539aa50025d8218ae4f0145 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sat, 20 Jun 2026 07:37:52 -0400 Subject: [PATCH 11/11] feat(signals): drop "Edit as MQL" toggle The chip-builder row already carries its own inline-editable MQL line at the bottom, so the global mode swap to a single-textarea editor was duplicate surface. Removes the toggle button + the `editAsMql` state + the `MqlEditor` import. The `textToQueries` helper from the same module is still used elsewhere so the module itself stays. --- .../src/components/signals/SignalWidget.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index d57a9464..9b13ec47 100644 --- a/dashboard/src/components/signals/SignalWidget.tsx +++ b/dashboard/src/components/signals/SignalWidget.tsx @@ -31,7 +31,6 @@ import { } from "@/components/signals/QueryChart"; import { buildSeriesVariables } from "@/lib/derived"; import { - MqlEditor, textToQueries, type QueryStmt, } from "@/components/signals/MqlEditor"; @@ -234,7 +233,6 @@ export function SignalWidget({ const [queries, setQueries] = useState([ { id: newQueryId(), mql: "count(signal.name)" }, ]); - const [editAsMql, setEditAsMql] = useState(false); // While a row's field is focused, freeze its kind: `looksLikeFetchQuery` // flips at the `(`, and re-classifying mid-type would swap the input element // and yank the caret. Re-classified on blur. @@ -685,12 +683,10 @@ export function SignalWidget({
- {/* Trace list — one row per statement; the "Edit as MQL" toggle - swaps it for a single textarea. Both write the same `queries`. */} + {/* Trace list — one row per statement. Each chip row carries + its own inline-editable MQL line, so power users can drop + to text without a global mode toggle. */}
- {editAsMql ? ( - - ) : (
{classified.map((c) => { const id = c.stmt.id; @@ -773,16 +769,6 @@ export function SignalWidget({ Add query
- )} - - {/* Edit-as-MQL toggle — swaps chip rows ↔ one textarea. */} -
{/* Advanced options: per-trace y-axis scaling + visibility,