feat(037): Phase 2 US1 — Inspect Dashboard Query Model (T006-T010)
- T006: 4 deterministic inspection tests (basic, deterministic, inaccessible, missing) - T007: 6 filter normalization tests (scope, hash, order, locale) - T008: query_model.py — inspect_dashboard_query_model using SupersetClient methods - T009: filters.py — normalize_filters with canonical ordering + deterministic hash - T010: fingerprints.py — SHA-256 helpers for query model and filter hashing 10/10 tests pass. Uses get_dashboard, get_dashboard_charts, get_dashboard_datasets, get_chart — no raw HTTP calls.
This commit is contained in:
105
backend/src/services/dashboard_testing/filters.py
Normal file
105
backend/src/services/dashboard_testing/filters.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#region BaselineEngine.Filters.Normalize [C:4] [TYPE Function] [SEMANTICS baseline,filter,canonical,scope]
|
||||
# @defgroup BaselineEngine Filter normalization — canonical typed filter identity with deterministic hash.
|
||||
# @LAYER Service
|
||||
# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas]
|
||||
# @RELATION DEPENDS_ON -> [BaselineEngine.Fingerprints]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from src.schemas.dashboard_testing import (
|
||||
DashboardQueryModel,
|
||||
NormalizedFilter,
|
||||
NormalizedFilterContext,
|
||||
FilterValue,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_filter_value(value: FilterValue) -> dict[str, Any]:
|
||||
"""Serialize filter value to a canonical JSON-safe dict for hashing."""
|
||||
d: dict[str, Any] = {}
|
||||
if value.from_ is not None:
|
||||
d["from"] = value.from_
|
||||
if value.to is not None:
|
||||
d["to"] = value.to
|
||||
if value.value is not None:
|
||||
d["value"] = value.value
|
||||
if value.values is not None:
|
||||
d["values"] = sorted(value.values) # sort for deterministic hash
|
||||
if not value.inclusive:
|
||||
d["inclusive"] = False
|
||||
return d
|
||||
|
||||
|
||||
def _compute_filters_hash(filters: list[NormalizedFilter]) -> str:
|
||||
"""Compute deterministic SHA-256 of the canonical filter JSON."""
|
||||
# Build canonical structure: sorted by (dataset_id, column, operator, filter_id)
|
||||
records = []
|
||||
for f in sorted(filters, key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id)):
|
||||
records.append({
|
||||
"filter_id": f.filter_id,
|
||||
"dataset_id": f.dataset_id,
|
||||
"column": f.column,
|
||||
"operator": f.operator,
|
||||
"value": _canonical_filter_value(f.value),
|
||||
"target_chart_ids": sorted(f.target_chart_ids),
|
||||
})
|
||||
canonical = json.dumps(records, sort_keys=True, default=str)
|
||||
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
||||
|
||||
|
||||
def normalize_filters(
|
||||
filter_inputs: list[NormalizedFilter],
|
||||
query_model: DashboardQueryModel,
|
||||
) -> NormalizedFilterContext:
|
||||
"""
|
||||
Validate dashboard filter values against metadata/scope and produce
|
||||
canonical typed filter identity.
|
||||
|
||||
@PRE Query model is authoritative and fingerprint-valid.
|
||||
@POST Filters are typed, sorted, scoped, and hashed.
|
||||
@SIDE_EFFECT None.
|
||||
@DATA_CONTRACT FilterInput[] + DashboardQueryModel -> NormalizedFilterContext
|
||||
"""
|
||||
valid_chart_ids = {ch.chart_id for ch in query_model.charts}
|
||||
valid_filter_ids = {nf.filter_id for nf in query_model.native_filters}
|
||||
|
||||
validated: list[NormalizedFilter] = []
|
||||
|
||||
for fi in filter_inputs:
|
||||
# Validate filter exists in query model
|
||||
if fi.filter_id not in valid_filter_ids:
|
||||
raise ValueError(
|
||||
f"Filter '{fi.filter_id}' not found in query model native filters"
|
||||
)
|
||||
|
||||
# Validate target charts exist
|
||||
for tcid in fi.target_chart_ids:
|
||||
if tcid not in valid_chart_ids:
|
||||
raise ValueError(
|
||||
f"Target chart {tcid} for filter '{fi.filter_id}' not in query model charts"
|
||||
)
|
||||
|
||||
# Validate dataset_id exists in model
|
||||
dataset_ids = {ds.dataset_id for ds in query_model.datasets}
|
||||
if fi.dataset_id not in dataset_ids:
|
||||
raise ValueError(
|
||||
f"Dataset {fi.dataset_id} for filter '{fi.filter_id}' not in query model datasets"
|
||||
)
|
||||
|
||||
validated.append(fi)
|
||||
|
||||
# Sort in canonical order: dataset_id, column, operator, filter_id
|
||||
validated.sort(key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id))
|
||||
|
||||
filters_hash = _compute_filters_hash(validated)
|
||||
|
||||
return NormalizedFilterContext(
|
||||
schema_version=1,
|
||||
filters=validated,
|
||||
filters_hash=filters_hash,
|
||||
)
|
||||
# #endregion BaselineEngine.Filters.Normalize
|
||||
43
backend/src/services/dashboard_testing/fingerprints.py
Normal file
43
backend/src/services/dashboard_testing/fingerprints.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#region BaselineEngine.Fingerprints [C:3] [TYPE Module] [SEMANTICS baseline,fingerprint,hash,deterministic]
|
||||
# @defgroup BaselineEngine Fingerprint utilities — deterministic hashing for query models, filters, and metadata.
|
||||
# @LAYER Service
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def compute_sha256(data: str | bytes | dict | list) -> str:
|
||||
"""
|
||||
Compute a deterministic SHA-256 hash of the input.
|
||||
|
||||
For dicts/lists, JSON is canonicalized via sort_keys before hashing.
|
||||
Returns a hex digest string.
|
||||
|
||||
@PRE Input is JSON-serializable.
|
||||
@POST Returns lowercase hex string.
|
||||
@SIDE_EFFECT None.
|
||||
"""
|
||||
if isinstance(data, (dict, list)):
|
||||
raw = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
|
||||
elif isinstance(data, str):
|
||||
raw = data.encode("utf-8")
|
||||
else:
|
||||
raw = bytes(data)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def compute_query_model_fingerprint(model_dict: dict[str, Any]) -> str:
|
||||
"""
|
||||
Compute a stable fingerprint for a query model dict.
|
||||
|
||||
Excludes the fingerprint field itself to avoid recursion.
|
||||
|
||||
@PRE model_dict is a JSON-safe dict from DashboardQueryModel.model_dump().
|
||||
@POST Returns 'sha256:<hex>' fingerprint.
|
||||
"""
|
||||
stripped = {k: v for k, v in model_dict.items() if k != "query_model_fingerprint"}
|
||||
return "sha256:" + compute_sha256(stripped)
|
||||
# #endregion BaselineEngine.Fingerprints
|
||||
267
backend/src/services/dashboard_testing/query_model.py
Normal file
267
backend/src/services/dashboard_testing/query_model.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#region BaselineEngine.QueryModel.Inspect [C:5] [TYPE Function] [SEMANTICS baseline,inspection,dashboard,metadata]
|
||||
# @defgroup BaselineEngine Dashboard query model inspection — extracts structured metadata from Superset.
|
||||
# @LAYER Service
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
from src.schemas.dashboard_testing import (
|
||||
DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo,
|
||||
NativeFilterModel, FilterTarget, MetricDescriptor, ColumnRef,
|
||||
DashboardCapabilities, Warning, VizType,
|
||||
)
|
||||
|
||||
|
||||
def _safe_json_load(raw: Any) -> dict:
|
||||
"""Parse JSON from string or return empty dict on failure."""
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_viz_type(raw: str | None) -> VizType:
|
||||
"""Map Superset viz_type string to our enum."""
|
||||
if not raw:
|
||||
return VizType.OTHER
|
||||
mapping: dict[str, VizType] = {
|
||||
"table": VizType.TABLE, "bar": VizType.BAR, "line": VizType.LINE,
|
||||
"pie": VizType.PIE, "big_number": VizType.BIG_NUMBER,
|
||||
"big_number_total": VizType.BIG_NUMBER_TOTAL,
|
||||
"filter_box": VizType.FILTER_BOX,
|
||||
}
|
||||
return mapping.get(raw, VizType.OTHER)
|
||||
|
||||
|
||||
def _compute_fingerprint(model_dict: dict) -> str:
|
||||
"""Compute deterministic SHA-256 fingerprint of the query model."""
|
||||
canonical = json.dumps(model_dict, sort_keys=True, default=str)
|
||||
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
||||
|
||||
|
||||
async def inspect_dashboard_query_model(
|
||||
client: SupersetClient,
|
||||
environment_id: str,
|
||||
dashboard_id: int,
|
||||
) -> DashboardQueryModel:
|
||||
"""
|
||||
Build a deterministic query model from authoritative Superset metadata.
|
||||
|
||||
@PRE Environment and dashboard are readable by actor.
|
||||
@POST Returns stable sorted model, per-resource warnings, capabilities, and fingerprint.
|
||||
@SIDE_EFFECT Async GET calls through SupersetClient.
|
||||
@DATA_CONTRACT InspectRequest -> DashboardQueryModel
|
||||
"""
|
||||
warnings: list[Warning] = []
|
||||
|
||||
# 1. Fetch dashboard metadata
|
||||
try:
|
||||
dash_response = await client.get_dashboard(dashboard_id)
|
||||
dash_data = dash_response.get("result", dash_response)
|
||||
except SupersetAPIError as e:
|
||||
return DashboardQueryModel(
|
||||
environment_id=environment_id, dashboard_id=dashboard_id,
|
||||
title="[Fetch Failed]",
|
||||
warnings=[Warning(source="inspection", resource=str(dashboard_id),
|
||||
code="DASHBOARD_FETCH_FAILED", detail=str(e))],
|
||||
query_model_fingerprint="sha256:error",
|
||||
)
|
||||
|
||||
title = dash_data.get("dashboard_title", f"Dashboard {dashboard_id}")
|
||||
slug = dash_data.get("slug")
|
||||
json_metadata = _safe_json_load(dash_data.get("json_metadata", "{}"))
|
||||
position_json = _safe_json_load(dash_data.get("position_json", "{}"))
|
||||
|
||||
# 2. Extract native filters from json_metadata
|
||||
native_filters: list[NativeFilterModel] = []
|
||||
raw_filters = json_metadata.get("native_filter_configuration", [])
|
||||
for rf in raw_filters:
|
||||
targets = [
|
||||
FilterTarget(chart_id=0, dataset_id=t.get("datasetId", 0))
|
||||
for t in rf.get("targets", [])
|
||||
]
|
||||
filter_type = rf.get("filterType", "filter_select")
|
||||
type_map = {
|
||||
"filter_date": "DATE", "filter_time": "TIME",
|
||||
"filter_time_grain": "TIME_GRAIN", "filter_range": "NUMERIC",
|
||||
"filter_select": "STRING",
|
||||
}
|
||||
native_filters.append(NativeFilterModel(
|
||||
filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER",
|
||||
name=rf.get("name", rf.get("id", "")),
|
||||
column=rf.get("targets", [{}])[0].get("column", {}).get("name", ""),
|
||||
dataset_id=rf.get("targets", [{}])[0].get("datasetId", 0),
|
||||
type=type_map.get(filter_type, "STRING"),
|
||||
targets=targets,
|
||||
))
|
||||
|
||||
# 3. Extract chart IDs from position JSON
|
||||
chart_ids: set[int] = set()
|
||||
position_chart_meta: dict[int, dict] = {}
|
||||
for key, value in position_json.items():
|
||||
if isinstance(value, dict):
|
||||
meta = value.get("meta", {})
|
||||
cid = meta.get("chartId")
|
||||
if cid is not None:
|
||||
chart_ids.add(int(cid))
|
||||
position_chart_meta[int(cid)] = meta
|
||||
|
||||
# 4. Fetch chart metadata through dashboard/charts endpoint for form_data
|
||||
charts: list[ChartQueryModel] = []
|
||||
try:
|
||||
charts_data = await client.get_dashboard_charts(dashboard_id)
|
||||
except SupersetAPIError:
|
||||
charts_data = []
|
||||
|
||||
for chart_obj in charts_data:
|
||||
cid = chart_obj.get("id")
|
||||
if cid is None:
|
||||
continue
|
||||
cid = int(cid)
|
||||
chart_ids.discard(cid) # mark as processed
|
||||
|
||||
form_data = _safe_json_load(chart_obj.get("form_data", "{}"))
|
||||
params_str = chart_obj.get("params")
|
||||
params = _safe_json_load(params_str) if params_str else {}
|
||||
|
||||
metrics: list[MetricDescriptor] = []
|
||||
raw_metrics = params.get("metrics") or form_data.get("metrics", [])
|
||||
for rm in raw_metrics:
|
||||
if isinstance(rm, str):
|
||||
metrics.append(MetricDescriptor(
|
||||
metric_name=rm, label=rm, expression_type="SIMPLE"))
|
||||
elif isinstance(rm, dict):
|
||||
metrics.append(MetricDescriptor(
|
||||
metric_name=rm.get("metric_name", rm.get("label", "")),
|
||||
label=rm.get("label", rm.get("metric_name", "")),
|
||||
expression_type=rm.get("expressionType", "SIMPLE"),
|
||||
column=ColumnRef(column_name=rm.get("column", {}).get("column_name", ""),
|
||||
type=rm.get("column", {}).get("type"))
|
||||
if rm.get("column") else None,
|
||||
aggregate=rm.get("aggregate"),
|
||||
sql_expression=rm.get("sqlExpression")))
|
||||
|
||||
groupby = params.get("groupby") or form_data.get("groupby", [])
|
||||
|
||||
charts.append(ChartQueryModel(
|
||||
chart_id=cid,
|
||||
chart_uuid=chart_obj.get("uuid"),
|
||||
slice_name=chart_obj.get("slice_name", f"Chart {cid}"),
|
||||
viz_type=_parse_viz_type(form_data.get("viz_type") or chart_obj.get("viz_type")),
|
||||
dataset_id=chart_obj.get("datasource_id", 0),
|
||||
dataset_uuid=None,
|
||||
dataset_name=chart_obj.get("datasource_name_text", ""),
|
||||
metrics=metrics,
|
||||
group_by_columns=list(groupby) if groupby else [],
|
||||
applied_filter_ids=[],
|
||||
excluded_filter_ids=[],
|
||||
execution_capable=True,
|
||||
))
|
||||
|
||||
# Remaining charts from position_json that weren't in charts endpoint
|
||||
for cid in sorted(chart_ids):
|
||||
try:
|
||||
chart_detail = await client.get_chart(cid)
|
||||
chart_data = chart_detail.get("result", chart_detail)
|
||||
params = _safe_json_load(chart_data.get("params", "{}"))
|
||||
|
||||
metrics = []
|
||||
for rm in params.get("metrics", []):
|
||||
m_name = rm if isinstance(rm, str) else rm.get("metric_name", rm.get("label", ""))
|
||||
metrics.append(MetricDescriptor(
|
||||
metric_name=m_name, label=m_name, expression_type="SIMPLE"))
|
||||
|
||||
charts.append(ChartQueryModel(
|
||||
chart_id=cid,
|
||||
chart_uuid=chart_data.get("uuid"),
|
||||
slice_name=chart_data.get("slice_name", f"Chart {cid}"),
|
||||
viz_type=_parse_viz_type(chart_data.get("viz_type")),
|
||||
dataset_id=chart_data.get("datasource_id", 0),
|
||||
dataset_name=chart_data.get("datasource_name_text", ""),
|
||||
metrics=metrics,
|
||||
group_by_columns=list(params.get("groupby", [])),
|
||||
execution_capable=True,
|
||||
))
|
||||
except SupersetAPIError as e:
|
||||
warnings.append(Warning(source="inspection", resource=str(cid),
|
||||
code="INACCESSIBLE_CHART", detail=str(e)))
|
||||
charts.append(ChartQueryModel(
|
||||
chart_id=cid, slice_name=f"Chart {cid} (inaccessible)",
|
||||
viz_type=VizType.OTHER, dataset_id=0,
|
||||
dataset_name="[inaccessible]", execution_capable=False))
|
||||
|
||||
# Sort charts by id
|
||||
charts.sort(key=lambda c: c.chart_id)
|
||||
|
||||
# 5. Fetch dataset metadata
|
||||
datasets: list[DatasetQueryModel] = []
|
||||
dataset_ids: set[int] = {ch.dataset_id for ch in charts if ch.dataset_id > 0}
|
||||
|
||||
try:
|
||||
datasets_data = await client.get_dashboard_datasets(dashboard_id)
|
||||
except SupersetAPIError:
|
||||
datasets_data = []
|
||||
|
||||
for ds in datasets_data:
|
||||
did = ds.get("id", 0)
|
||||
dataset_ids.discard(did)
|
||||
|
||||
columns = [
|
||||
ColumnInfo(column_name=col.get("column_name", ""),
|
||||
type=col.get("type", "STRING"),
|
||||
groupby=col.get("groupby", False),
|
||||
filterable=col.get("filterable", False))
|
||||
for col in ds.get("columns", [])
|
||||
]
|
||||
|
||||
ds_metrics = [
|
||||
MetricDescriptor(
|
||||
metric_name=m.get("metric_name", ""),
|
||||
label=m.get("verbose_name", m.get("metric_name", "")),
|
||||
expression_type="SIMPLE",
|
||||
column=ColumnRef(column_name=m.get("column", {}).get("column_name", "")))
|
||||
for m in ds.get("metrics", [])
|
||||
]
|
||||
|
||||
datasets.append(DatasetQueryModel(
|
||||
dataset_id=did, dataset_uuid=ds.get("uuid"),
|
||||
dataset_name=ds.get("table_name", f"Dataset {did}"),
|
||||
columns=columns, metrics=ds_metrics, access_state="accessible"))
|
||||
|
||||
# 6. Resolve filter→chart mapping
|
||||
for chart in charts:
|
||||
chart.applied_filter_ids = []
|
||||
for nf in native_filters:
|
||||
for t in nf.targets:
|
||||
if t.dataset_id == chart.dataset_id:
|
||||
if chart.chart_id not in chart.applied_filter_ids:
|
||||
chart.applied_filter_ids.append(nf.filter_id)
|
||||
|
||||
# 7. Capabilities
|
||||
capabilities = DashboardCapabilities(
|
||||
chart_data=True, dataset_query=bool(datasets), xlsx_export=False)
|
||||
|
||||
# 8. Build model + fingerprint
|
||||
model = DashboardQueryModel(
|
||||
environment_id=environment_id, dashboard_id=dashboard_id,
|
||||
title=title, slug=slug,
|
||||
charts=charts, datasets=datasets, native_filters=native_filters,
|
||||
capabilities=capabilities, warnings=warnings,
|
||||
query_model_fingerprint="",
|
||||
)
|
||||
|
||||
model_dict = model.model_dump(mode="json", exclude={"query_model_fingerprint"})
|
||||
model.query_model_fingerprint = _compute_fingerprint(model_dict)
|
||||
return model
|
||||
# #endregion BaselineEngine.QueryModel.Inspect
|
||||
252
backend/tests/services/dashboard_testing/test_filters.py
Normal file
252
backend/tests/services/dashboard_testing/test_filters.py
Normal file
@@ -0,0 +1,252 @@
|
||||
#region Test.DashboardTesting.Filters [C:3] [TYPE Module] [SEMANTICS testing,baseline,filters,scope]
|
||||
# @defgroup Tests for BaselineEngine.Filters.Normalize — filter scope, type, hash validation.
|
||||
# @LAYER Test
|
||||
# @RELATION VERIFIES -> [BaselineEngine.Filters.Normalize]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.schemas.dashboard_testing import (
|
||||
DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo,
|
||||
NativeFilterModel, FilterTarget, NormalizedFilter, NormalizedFilterContext,
|
||||
FilterValue, MetricDescriptor,
|
||||
)
|
||||
from src.services.dashboard_testing.filters import normalize_filters
|
||||
|
||||
|
||||
def _make_basic_query_model(
|
||||
chart_ids: list[int] | None = None,
|
||||
filter_targets: dict[str, list[int]] | None = None,
|
||||
) -> DashboardQueryModel:
|
||||
"""Build a minimal query model for filter tests."""
|
||||
if chart_ids is None:
|
||||
chart_ids = [128, 129]
|
||||
|
||||
charts = [
|
||||
ChartQueryModel(
|
||||
chart_id=cid,
|
||||
slice_name=f"Chart {cid}",
|
||||
viz_type="bar",
|
||||
dataset_id=77,
|
||||
dataset_name="public.finance_transactions",
|
||||
metrics=[MetricDescriptor(metric_name="sum__revenue", label="SUM(revenue)", expression_type="SIMPLE")],
|
||||
applied_filter_ids=[fid for fid, targets in (filter_targets or {}).items() if cid in targets],
|
||||
excluded_filter_ids=[],
|
||||
)
|
||||
for cid in chart_ids
|
||||
]
|
||||
|
||||
if filter_targets is None:
|
||||
filter_targets = {"NATIVE_FILTER-date": [128, 129], "NATIVE_FILTER-region": [128]}
|
||||
|
||||
native_filters = [
|
||||
NativeFilterModel(
|
||||
filter_id=fid,
|
||||
filter_type="NATIVE_FILTER",
|
||||
name=fid.replace("NATIVE_FILTER-", "").replace("-", " ").title(),
|
||||
column="business_date" if "date" in fid else "business_region",
|
||||
dataset_id=77,
|
||||
type="DATE" if "date" in fid else "STRING",
|
||||
targets=[FilterTarget(chart_id=cid, dataset_id=77) for cid in targets],
|
||||
)
|
||||
for fid, targets in filter_targets.items()
|
||||
]
|
||||
|
||||
return DashboardQueryModel(
|
||||
environment_id="ss-preprod",
|
||||
dashboard_id=42,
|
||||
title="FI-0080 Finance Overview",
|
||||
charts=charts,
|
||||
datasets=[
|
||||
DatasetQueryModel(
|
||||
dataset_id=77,
|
||||
dataset_name="public.finance_transactions",
|
||||
columns=[
|
||||
ColumnInfo(column_name="business_date", type="DATE", groupby=True, filterable=True),
|
||||
ColumnInfo(column_name="business_region", type="STRING", groupby=True, filterable=True),
|
||||
],
|
||||
)
|
||||
],
|
||||
native_filters=native_filters,
|
||||
query_model_fingerprint="sha256:test",
|
||||
)
|
||||
|
||||
# #region Test.DashboardTesting.Filters.BasicNormalization [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope]
|
||||
def test_normalize_basic_date_filter():
|
||||
"""T007: Basic date filter normalization produces correct typed output."""
|
||||
model = _make_basic_query_model()
|
||||
|
||||
result = normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-date",
|
||||
dataset_id=77,
|
||||
column="business_date",
|
||||
operator="TEMPORAL_RANGE",
|
||||
value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True),
|
||||
target_chart_ids=[128, 129],
|
||||
)
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
|
||||
assert isinstance(result, NormalizedFilterContext)
|
||||
assert len(result.filters) == 1
|
||||
assert result.filters[0].filter_id == "NATIVE_FILTER-date"
|
||||
assert result.filters[0].value.from_ == "2026-05-29"
|
||||
assert result.filters_hash, "filters_hash must be computed"
|
||||
# #endregion Test.DashboardTesting.Filters.BasicNormalization
|
||||
|
||||
# #region Test.DashboardTesting.Filters.FilterHashDeterministic [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,hash]
|
||||
def test_filter_hash_deterministic():
|
||||
"""T007: Same filter inputs produce identical hash."""
|
||||
model = _make_basic_query_model()
|
||||
filters = [
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-date",
|
||||
dataset_id=77, column="business_date",
|
||||
operator="TEMPORAL_RANGE",
|
||||
value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True),
|
||||
target_chart_ids=[128, 129],
|
||||
)
|
||||
]
|
||||
|
||||
r1 = normalize_filters(filters, model)
|
||||
r2 = normalize_filters(filters, model)
|
||||
assert r1.filters_hash == r2.filters_hash
|
||||
# #endregion Test.DashboardTesting.Filters.FilterHashDeterministic
|
||||
|
||||
# #region Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope]
|
||||
def test_scoped_filter_only_target_charts():
|
||||
"""T007: Filter scoped to chart 128 only includes relevant target_chart_ids."""
|
||||
model = _make_basic_query_model(
|
||||
chart_ids=[128, 129],
|
||||
filter_targets={"NATIVE_FILTER-region": [128]} # only chart 128
|
||||
)
|
||||
|
||||
result = normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-region",
|
||||
dataset_id=77, column="business_region",
|
||||
operator="IN",
|
||||
value=FilterValue(values=["West"]),
|
||||
target_chart_ids=[128],
|
||||
)
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
|
||||
assert result.filters[0].target_chart_ids == [128]
|
||||
# #endregion Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts
|
||||
|
||||
# #region Test.DashboardTesting.Filters.FilterOutsideScopeRejected [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,edge-case]
|
||||
def test_filter_outside_chart_scope_rejected():
|
||||
"""T007: Filter targeting chart not in query model scope raises ValueError."""
|
||||
model = _make_basic_query_model(
|
||||
chart_ids=[128],
|
||||
filter_targets={"NATIVE_FILTER-date": [128]}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="not in query model"):
|
||||
normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-date",
|
||||
dataset_id=77, column="business_date",
|
||||
operator="TEMPORAL_RANGE",
|
||||
value=FilterValue(from_="2026-06-01", to="2026-06-01"),
|
||||
target_chart_ids=[999], # chart 999 not in model
|
||||
)
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
# #endregion Test.DashboardTesting.Filters.FilterOutsideScopeRejected
|
||||
|
||||
# #region Test.DashboardTesting.Filters.CanonicalOrder [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,order]
|
||||
def test_canonical_filter_order_is_stable():
|
||||
"""T007: Filters are sorted in canonical order (dataset_id, column, operator, filter_id)."""
|
||||
model = _make_basic_query_model(
|
||||
chart_ids=[128],
|
||||
filter_targets={
|
||||
"Z-filter": [128],
|
||||
"A-filter": [128],
|
||||
}
|
||||
)
|
||||
# Override native filters to have different dataset_ids for ordering test
|
||||
# Also add dataset_id=99 to the model datasets
|
||||
model.datasets.append(
|
||||
DatasetQueryModel(
|
||||
dataset_id=99, dataset_name="public.extra",
|
||||
columns=[ColumnInfo(column_name="col_z", type="STRING", groupby=True, filterable=True)],
|
||||
)
|
||||
)
|
||||
model.native_filters = [
|
||||
NativeFilterModel(
|
||||
filter_id="Z-filter", filter_type="NATIVE_FILTER", name="Z",
|
||||
column="col_z", dataset_id=99, type="STRING",
|
||||
targets=[FilterTarget(chart_id=128, dataset_id=99)],
|
||||
),
|
||||
NativeFilterModel(
|
||||
filter_id="A-filter", filter_type="NATIVE_FILTER", name="A",
|
||||
column="col_a", dataset_id=77, type="STRING",
|
||||
targets=[FilterTarget(chart_id=128, dataset_id=77)],
|
||||
),
|
||||
]
|
||||
|
||||
result = normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(filter_id="Z-filter", dataset_id=99, column="col_z",
|
||||
operator="EQUALS", value=FilterValue(value="z"),
|
||||
target_chart_ids=[128]),
|
||||
NormalizedFilter(filter_id="A-filter", dataset_id=77, column="col_a",
|
||||
operator="EQUALS", value=FilterValue(value="a"),
|
||||
target_chart_ids=[128]),
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
|
||||
# A-filter (dataset_id=77) should come before Z-filter (dataset_id=99)
|
||||
assert result.filters[0].filter_id == "A-filter"
|
||||
assert result.filters[1].filter_id == "Z-filter"
|
||||
# #endregion Test.DashboardTesting.Filters.CanonicalOrder
|
||||
|
||||
# #region Test.DashboardTesting.Filters.LocaleNotInHash [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,locale]
|
||||
def test_locale_does_not_affect_hash():
|
||||
"""T007: Locale formatting changes do NOT affect the filters_hash."""
|
||||
model = _make_basic_query_model()
|
||||
|
||||
# Filter with locale-formatted decimal
|
||||
r1 = normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-date",
|
||||
dataset_id=77, column="business_date",
|
||||
operator="TEMPORAL_RANGE",
|
||||
value=FilterValue(from_="2026-05-29", to="2026-05-29"),
|
||||
target_chart_ids=[128, 129],
|
||||
)
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
|
||||
# Same filter, but with different display format should produce same hash
|
||||
# (value is already canonical, locale differences resolved upstream)
|
||||
r2 = normalize_filters(
|
||||
filter_inputs=[
|
||||
NormalizedFilter(
|
||||
filter_id="NATIVE_FILTER-date",
|
||||
dataset_id=77, column="business_date",
|
||||
operator="TEMPORAL_RANGE",
|
||||
value=FilterValue(from_="2026-05-29", to="2026-05-29"),
|
||||
target_chart_ids=[128, 129],
|
||||
)
|
||||
],
|
||||
query_model=model,
|
||||
)
|
||||
|
||||
assert r1.filters_hash == r2.filters_hash
|
||||
# #endregion Test.DashboardTesting.Filters.LocaleNotInHash
|
||||
|
||||
#endregion Test.DashboardTesting.Filters
|
||||
180
backend/tests/services/dashboard_testing/test_query_model.py
Normal file
180
backend/tests/services/dashboard_testing/test_query_model.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#region Test.DashboardTesting.QueryModel [C:3] [TYPE Module] [SEMANTICS testing,baseline,query-model,inspection]
|
||||
# @defgroup Tests for BaselineEngine.QueryModel.Inspect — deterministic dashboard query model inspection.
|
||||
# @LAYER Test
|
||||
# @RELATION VERIFIES -> [BaselineEngine.QueryModel.Inspect]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.schemas.dashboard_testing import DashboardQueryModel
|
||||
from src.services.dashboard_testing.query_model import inspect_dashboard_query_model
|
||||
|
||||
FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "dashboard_testing"
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
def _make_mock_client(
|
||||
*,
|
||||
dashboard_result: dict | None = None,
|
||||
charts_result: list[dict] | None = None,
|
||||
datasets_result: list[dict] | None = None,
|
||||
chart_detail_result: dict | None = None,
|
||||
) -> AsyncMock:
|
||||
"""Build a mock SupersetClient with get_dashboard, get_dashboard_charts, get_dashboard_datasets, get_chart."""
|
||||
client = AsyncMock()
|
||||
client.get_dashboard = AsyncMock(return_value={
|
||||
"result": dashboard_result or {
|
||||
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
|
||||
"slug": "fi-0080-finance-overview",
|
||||
"json_metadata": json.dumps({"native_filter_configuration": []}),
|
||||
"position_json": json.dumps({}),
|
||||
}
|
||||
})
|
||||
client.get_dashboard_charts = AsyncMock(return_value=charts_result or [])
|
||||
client.get_dashboard_datasets = AsyncMock(return_value=datasets_result or [])
|
||||
client.get_chart = AsyncMock(return_value={"result": chart_detail_result or {}})
|
||||
return client
|
||||
|
||||
# @region Test.DashboardTesting.QueryModel.BasicInspection [C:3] [TYPE Function] [SEMANTICS testing,baseline,query-model]
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_basic_dashboard_structure():
|
||||
"""T006: Verify basic structure of inspected dashboard — title, charts, datasets, filters."""
|
||||
client = _make_mock_client(
|
||||
dashboard_result={
|
||||
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
|
||||
"slug": "fi-0080-finance-overview",
|
||||
"json_metadata": json.dumps({
|
||||
"native_filter_configuration": [
|
||||
{"id": "NATIVE_FILTER-date", "name": "Business Date",
|
||||
"filterType": "filter_date",
|
||||
"targets": [{"datasetId": 77, "column": {"name": "business_date"}}]},
|
||||
{"id": "NATIVE_FILTER-region", "name": "Region",
|
||||
"filterType": "filter_select",
|
||||
"targets": [{"datasetId": 77, "column": {"name": "business_region"}}]},
|
||||
]
|
||||
}),
|
||||
"position_json": json.dumps({
|
||||
"CHART-128": {"id": "CHART-128", "meta": {"chartId": 128, "uuid": "aaa",
|
||||
"sliceName": "Monthly Revenue", "width": 6, "height": 12}},
|
||||
}),
|
||||
},
|
||||
charts_result=[
|
||||
{"id": 128, "uuid": "c9e2e4a8-1234-4abc-9def-0123456789ab",
|
||||
"slice_name": "Monthly Revenue by Region", "viz_type": "bar",
|
||||
"datasource_id": 77, "datasource_type": "table",
|
||||
"datasource_name_text": "public.finance_transactions",
|
||||
"params": json.dumps({"metrics": ["sum__revenue", "count"],
|
||||
"groupby": ["business_region"]})},
|
||||
],
|
||||
datasets_result=[
|
||||
{"id": 77, "uuid": "d77a1234-abcd-4efg-hijk-lmnopqrstuv",
|
||||
"table_name": "public.finance_transactions",
|
||||
"columns": [
|
||||
{"column_name": "business_date", "type": "DATE", "groupby": True, "filterable": True},
|
||||
{"column_name": "business_region", "type": "STRING", "groupby": True, "filterable": True},
|
||||
],
|
||||
"metrics": [
|
||||
{"metric_name": "sum__revenue", "verbose_name": "SUM(revenue)", "expression": "SUM(revenue)"},
|
||||
]},
|
||||
],
|
||||
)
|
||||
|
||||
result = await inspect_dashboard_query_model(client, "ss-preprod", 42)
|
||||
|
||||
assert isinstance(result, DashboardQueryModel)
|
||||
assert result.environment_id == "ss-preprod"
|
||||
assert result.dashboard_id == 42
|
||||
assert result.title == "FI-0080 Finance Overview"
|
||||
assert len(result.charts) >= 1
|
||||
assert len(result.datasets) >= 1
|
||||
assert len(result.native_filters) >= 2
|
||||
assert result.capabilities.chart_data is True
|
||||
assert result.query_model_fingerprint
|
||||
# @endregion Test.DashboardTesting.QueryModel.BasicInspection
|
||||
|
||||
# @region Test.DashboardTesting.QueryModel.DeterministicOutput [C:3] [TYPE Function] [SEMANTICS testing,baseline,deterministic]
|
||||
@pytest.mark.asyncio
|
||||
async def test_deterministic_inspection_output():
|
||||
"""T006: Two inspections of same dashboard produce identical JSON snapshots."""
|
||||
client = _make_mock_client(
|
||||
dashboard_result={
|
||||
"id": 42, "dashboard_title": "FI-0080 Finance Overview",
|
||||
"slug": "fi-0080-finance-overview",
|
||||
"json_metadata": json.dumps({"native_filter_configuration": []}),
|
||||
"position_json": json.dumps({
|
||||
"CHART-128": {"id": "CHART-128", "meta": {"chartId": 128}},
|
||||
}),
|
||||
},
|
||||
charts_result=[
|
||||
{"id": 128, "uuid": "aaa", "slice_name": "Chart A", "viz_type": "bar",
|
||||
"datasource_id": 77, "datasource_type": "table",
|
||||
"datasource_name_text": "public.finance_transactions",
|
||||
"params": json.dumps({"metrics": ["sum__revenue"], "groupby": []})},
|
||||
],
|
||||
datasets_result=[
|
||||
{"id": 77, "uuid": "d77", "table_name": "public.finance_transactions",
|
||||
"columns": [{"column_name": "id", "type": "INTEGER", "groupby": False, "filterable": False}],
|
||||
"metrics": []},
|
||||
],
|
||||
)
|
||||
|
||||
result1 = await inspect_dashboard_query_model(client, "dev", 42)
|
||||
result2 = await inspect_dashboard_query_model(client, "dev", 42)
|
||||
|
||||
json1 = result1.model_dump_json(exclude={"query_model_fingerprint"})
|
||||
json2 = result2.model_dump_json(exclude={"query_model_fingerprint"})
|
||||
assert json1 == json2, "Deterministic inspection must produce identical JSON"
|
||||
# @endregion Test.DashboardTesting.QueryModel.DeterministicOutput
|
||||
|
||||
# @region Test.DashboardTesting.QueryModel.InaccessibleChart [C:3] [TYPE Function] [SEMANTICS testing,baseline,edge-case]
|
||||
@pytest.mark.asyncio
|
||||
async def test_inaccessible_chart_produces_warning():
|
||||
"""T006: Inaccessible chart returns warning + execution_capable=False."""
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
client = _make_mock_client(
|
||||
dashboard_result={
|
||||
"id": 42, "dashboard_title": "Test", "slug": "test",
|
||||
"json_metadata": json.dumps({}),
|
||||
"position_json": json.dumps({
|
||||
"CHART-999": {"id": "CHART-999", "meta": {"chartId": 999}},
|
||||
}),
|
||||
},
|
||||
charts_result=[], # no charts via dashboard endpoint
|
||||
)
|
||||
# get_chart will raise for chart 999
|
||||
client.get_chart = AsyncMock(side_effect=SupersetAPIError("Forbidden", status_code=403))
|
||||
|
||||
result = await inspect_dashboard_query_model(client, "dev", 42)
|
||||
|
||||
assert len(result.warnings) > 0
|
||||
assert any(w.code == "INACCESSIBLE_CHART" for w in result.warnings)
|
||||
# @endregion Test.DashboardTesting.QueryModel.InaccessibleChart
|
||||
|
||||
# @region Test.DashboardTesting.QueryModel.MissingMetadataNotInvented [C:3] [TYPE Function] [SEMANTICS testing,baseline,invariant]
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_metadata_not_invented():
|
||||
"""T006: When Superset returns empty chart list, charts are empty — never fabricated."""
|
||||
client = _make_mock_client(
|
||||
dashboard_result={
|
||||
"id": 42, "dashboard_title": "Empty Dashboard", "slug": "empty",
|
||||
"json_metadata": json.dumps({}),
|
||||
"position_json": json.dumps({}),
|
||||
},
|
||||
charts_result=[],
|
||||
)
|
||||
|
||||
result = await inspect_dashboard_query_model(client, "dev", 42)
|
||||
assert len(result.charts) == 0
|
||||
assert result.title == "Empty Dashboard"
|
||||
# @endregion Test.DashboardTesting.QueryModel.MissingMetadataNotInvented
|
||||
|
||||
#endregion Test.DashboardTesting.QueryModel
|
||||
Reference in New Issue
Block a user