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 ? (
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