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..9f5e66d5 100644 --- a/dashboard/src/components/signals/QueryBuilder.tsx +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -27,7 +27,7 @@ import { cn } from "@/lib/utils"; import { useTextMirror } from "@/lib/useTextMirror"; import Fuse from "fuse.js"; import { ChevronDown, Plus, X } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useMemo, useState } from "react"; /** Per-series summary of the raw samples a `.reject(...)` clause cut before * aggregation. One entry per series (single entry with empty `tags` when the @@ -152,79 +152,125 @@ 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, + }; + + // 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"); + } + return (
- {/* Reads as a sentence: Show of where … */} -
- - ({ - 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} + {/* Series name (serializes to `-> ident`). Always visible so a query + author can label the result without hunting through a menu. Hidden + when breaking out by name — a per-group breakdown is already + labeled by its group values, so `-> name` would be ambiguous. */} + {!breakout ? ( +
+ + setLabel(e.target.value || undefined)} + placeholder="unnamed" + className="h-7 max-w-[220px] font-mono text-xs" /> - - - - {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} - /> - - ); - }) - )} - - +
+ ) : null} - + {/* 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. */} +
+ Signal - - - + 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} + /> + + ); + }) + )} + - - - - - - - - - {/* `-> name` labels the single result series; hidden while breaking - out (a breakdown is already labeled by its group values). */} - {!breakout ? ( - - - + + + ({ + 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)}> + name + + ) : null} + + {activeModifiers.rollup ? ( + setRollup(undefined)}> + + ) : null} + + {activeModifiers.reject ? ( + setReject(undefined)}> + + + ) : null} + + {activeModifiers.fill ? ( + setFill(undefined)}> + + + ) : null} + +
@@ -270,23 +316,116 @@ 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({ +/** 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"; + +/** 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: "by", + description: "Split results into one series per signal name.", + }, + { + kind: "rollup", + label: "rollup", + description: "Override the automatic bucket width.", + }, + { + kind: "reject", + label: "filter", + description: "Drop raw samples by value or sigma before aggregating.", + }, + { + kind: "fill", + label: "fill", + description: "Choose what to show when a bucket has no data.", + }, +]; + +/** "+ 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 ( @@ -304,6 +443,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 ( @@ -511,82 +667,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`. -function LabelChip({ - value, - onChange, -}: { - value: string | undefined; - onChange: (next: string | undefined) => void; -}) { - const [open, setOpen] = useState(false); - const [draft, setDraft] = useState(value ?? ""); - // Re-seed from the AST when it changes (e.g. the MQL line edits `-> name`). - useEffect(() => setDraft(value ?? ""), [value]); - const isDefault = !value; - const commit = () => { - onChange(draft); - setOpen(false); - }; - return ( - { if (!o) commit(); setOpen(o); }}> - - - - - 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 @@ -610,7 +690,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 +714,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 })); @@ -826,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… )} diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index 073d4c6f..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"; @@ -48,6 +47,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 +77,7 @@ import { Hand, Loader2, Map as MapIcon, + MoreVertical, MousePointer, Plus, Trash2, @@ -224,9 +231,8 @@ 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` // flips at the `(`, and re-classifying mid-type would swap the input element // and yank the caret. Re-classified on blur. @@ -572,7 +578,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) => @@ -677,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; @@ -765,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, @@ -818,54 +812,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 +887,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. 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..04fb3c85 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. */ @@ -129,11 +135,11 @@ export function serializeQuery(q: Query): string { } if (q.reject) { - out += `.reject(${serializeReject(q.reject)})`; + out += `.filter(${serializeReject(q.reject)})`; } if (q.rollup) { - out += `.every(${q.rollup})`; + out += `.rollup(${q.rollup})`; } if (q.fill) { @@ -270,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", "filter", "fill"]); /** Parse an MQL string into a validated Query AST. Never throws — returns a * result object with a {message, position} error on failure. */ @@ -302,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 ` + @@ -313,11 +316,11 @@ 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); + } 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); @@ -354,6 +357,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 +387,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 +515,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 +540,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 +554,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 +580,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..7c4ed10b 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() - .reject() .fill() +Grammar (v0.4 — method-chain, dotted fields): + () ( '.' '(' ')' )* ( '->' )? + methods: .where() .by(,...) .rollup() + .filter() .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")).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 @@ -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` @@ -189,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", "filter", "fill"} class _Cursor: @@ -253,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 @@ -276,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 " @@ -293,19 +290,19 @@ 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 - elif method == "reject": + rollup = _parse_rollup_args(c) + rollup_seen_pos = method_tok.pos + 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) @@ -357,8 +354,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 +382,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 +397,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 @@ -507,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": @@ -576,6 +588,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 +608,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 +637,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