chartwright 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chartwright-0.2.0/chartwright.egg-info → chartwright-0.2.1}/PKG-INFO +1 -1
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/__init__.py +1 -1
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/compiler.py +56 -10
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/smoke.py +22 -3
- {chartwright-0.2.0 → chartwright-0.2.1/chartwright.egg-info}/PKG-INFO +1 -1
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/SOURCES.txt +1 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/pyproject.toml +1 -1
- chartwright-0.2.1/tests/test_chart_time_range.py +200 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_compiler.py +5 -1
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_mixed_chart.py +3 -1
- {chartwright-0.2.0 → chartwright-0.2.1}/LICENSE +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/NOTICE +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/README.md +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/absorb.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/apply.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/cli.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/client.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/dashdiff.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/decompile.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/__init__.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/brief.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/calibrate.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/fix.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/guidelines/chart-choice.md +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/guidelines/composition.md +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/model.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/presets.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/probe.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/redesign.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/design/rules.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/ids.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/mcp_server.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/profiles.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/resolver.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/sketch.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/spec.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright/testing.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/dependency_links.txt +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/entry_points.txt +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/requires.txt +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/chartwright.egg-info/top_level.txt +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/setup.cfg +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_absorb.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_apply_rollback.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_backup_layout.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_calibrate.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_client.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_column_suggestions.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_cross_filters.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_dashdiff.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_decompile.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_arch.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_data.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_gate.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_design_rules_v2.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_docs.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_layout_footer.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_markdown_heights.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_mcp_server.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_nested_tabs.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_params_contract.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_pivot_formatting.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_plan_errors.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_preset_auth.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_profiles.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_property_roundtrip.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_range_filter.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_redesign.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_review_tier_1_2.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_select_filter.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_sketch.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_smoke_fit.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_spec.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_spec_v2.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_table_formatting.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_table_sort.py +0 -0
- {chartwright-0.2.0 → chartwright-0.2.1}/tests/test_time_filter.py +0 -0
|
@@ -355,20 +355,66 @@ def _chart_params(chart, spec: DashboardSpec, resolution: Resolution) -> dict:
|
|
|
355
355
|
# (utils/mergeCustomEChartOptions.ts: arrays replace, so never a mixed chart's yAxis).
|
|
356
356
|
p["echart_options"] = json.dumps(p["echart_options"])
|
|
357
357
|
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
358
|
+
# A time range, the chart's own or a dashboard time filter's, reaches a query
|
|
359
|
+
# only through a time binding; without one the chart silently ignores it while
|
|
360
|
+
# the filter bar still counts it as filtered.
|
|
361
|
+
#
|
|
362
|
+
# The backend applies a query's time_range to the WHERE clause only through
|
|
363
|
+
# `granularity` (get_sqla_query, superset/models/helpers.py at 4.1.4, 5.0.0 and
|
|
364
|
+
# 6.1.0), which the frontend sends from granularity_sqla. A chart without a time
|
|
365
|
+
# axis binds the dataset's main time column there (verified live on 6.1.0: a
|
|
366
|
+
# full-month total under a one-week dashboard default before the binding). The
|
|
367
|
+
# categorical bar's x_axis is not a time column, so it needs the binding too.
|
|
368
|
+
#
|
|
369
|
+
# A chart drawn on a time axis can't take that binding: with granularity set,
|
|
370
|
+
# _apply_granularity (superset/common/query_context_factory.py) swaps the x-axis
|
|
371
|
+
# column for it. Superset's own charts filter the axis with an adhoc
|
|
372
|
+
# TEMPORAL_RANGE filter instead, applied whatever the granularity, and
|
|
373
|
+
# _apply_filters sets its value to the query's time_range: the chart's own, or
|
|
374
|
+
# the dashboard filter's. Without it, these charts returned every row on all
|
|
375
|
+
# three releases (seen live: a line chart limited to 2004-03-01 : 2005-03-02
|
|
376
|
+
# still drew 2003 to 2005).
|
|
377
|
+
axis = time_axis_column(chart, ds)
|
|
378
|
+
if axis:
|
|
379
|
+
temporal = _temporal_range_filter(axis, p["time_range"])
|
|
380
|
+
p["adhoc_filters"] = p["adhoc_filters"] + [temporal]
|
|
381
|
+
if t == "mixed":
|
|
382
|
+
p["adhoc_filters_b"] = p["adhoc_filters_b"] + [temporal]
|
|
383
|
+
elif ds.main_dttm_col:
|
|
368
384
|
p["granularity_sqla"] = ds.main_dttm_col
|
|
369
385
|
return p
|
|
370
386
|
|
|
371
387
|
|
|
388
|
+
# Chart types whose x axis is always their time column.
|
|
389
|
+
TIME_AXIS_TYPES = ("timeseries_line", "timeseries_bar", "timeseries_area",
|
|
390
|
+
"timeseries_scatter", "big_number_trend")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def time_axis_column(chart, ds) -> str | None:
|
|
394
|
+
"""The time column a chart draws its x axis on, or None for a chart without a
|
|
395
|
+
time axis. The compiler and smoke share it, so the smoke query is filtered the
|
|
396
|
+
way the chart is."""
|
|
397
|
+
if chart.type in TIME_AXIS_TYPES:
|
|
398
|
+
return chart.time_column
|
|
399
|
+
if chart.type == "mixed" and mixed_time_axis(chart, ds):
|
|
400
|
+
return chart.x_column
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _temporal_range_filter(column: str, time_range: str) -> dict:
|
|
405
|
+
# The shape Explore saves for the "Time range" filter pill (all three releases).
|
|
406
|
+
# Its comparator matches the chart's time_range: a non-empty time_range, "No
|
|
407
|
+
# filter" included, overwrites it at query time (_apply_filters), so the two
|
|
408
|
+
# can't disagree.
|
|
409
|
+
return {
|
|
410
|
+
"clause": "WHERE",
|
|
411
|
+
"expressionType": "SIMPLE",
|
|
412
|
+
"subject": column,
|
|
413
|
+
"operator": "TEMPORAL_RANGE",
|
|
414
|
+
"comparator": time_range,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
372
418
|
def mixed_time_axis(chart, ds) -> bool:
|
|
373
419
|
"""Whether a mixed chart's x column draws a time axis: the column's reported type
|
|
374
420
|
decides (a column flagged temporal counts), time_grain only when it is unknown.
|
|
@@ -17,7 +17,7 @@ import math
|
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
|
|
19
19
|
from .client import SupersetClient
|
|
20
|
-
from .compiler import _metric_payload, mixed_time_axis
|
|
20
|
+
from .compiler import _metric_payload, mixed_time_axis, time_axis_column
|
|
21
21
|
from .resolver import Resolution
|
|
22
22
|
from .spec import GRID_HEADER_UNITS, DashboardSpec, grid_units_for_rows
|
|
23
23
|
|
|
@@ -69,6 +69,24 @@ def _filters_payload(chart) -> list[dict]:
|
|
|
69
69
|
return [{"col": f.column, "op": f.op, "val": f.value} for f in chart.filters]
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def _with_time_range(q: dict, chart, ds) -> dict:
|
|
73
|
+
"""The chart's own time_range, bound the way the compiled chart binds it: a
|
|
74
|
+
TEMPORAL_RANGE filter on a time axis (compiler.time_axis_column), else the
|
|
75
|
+
dataset's main time column as granularity. A chart without a time_range, or
|
|
76
|
+
without a binding, keeps the unfiltered query."""
|
|
77
|
+
time_range = getattr(chart, "time_range", None)
|
|
78
|
+
if not time_range:
|
|
79
|
+
return q
|
|
80
|
+
axis = time_axis_column(chart, ds)
|
|
81
|
+
if axis:
|
|
82
|
+
q["time_range"] = time_range
|
|
83
|
+
q["filters"] = q["filters"] + [{"col": axis, "op": "TEMPORAL_RANGE", "val": time_range}]
|
|
84
|
+
elif ds.main_dttm_col:
|
|
85
|
+
q["time_range"] = time_range
|
|
86
|
+
q["granularity"] = ds.main_dttm_col
|
|
87
|
+
return q
|
|
88
|
+
|
|
89
|
+
|
|
72
90
|
def _time_axis(column: str, grain: str | None) -> dict:
|
|
73
91
|
return {
|
|
74
92
|
"columnType": "BASE_AXIS",
|
|
@@ -144,7 +162,7 @@ def _mixed_queries(chart, spec: DashboardSpec, ds) -> list[dict]:
|
|
|
144
162
|
out.append({
|
|
145
163
|
"filters": _filters_payload(chart),
|
|
146
164
|
"extras": {"time_grain_sqla": chart.time_grain or "P1D"},
|
|
147
|
-
"time_range":
|
|
165
|
+
"time_range": "No filter",
|
|
148
166
|
"row_limit": chart.row_limit or 1000,
|
|
149
167
|
"columns": [x] + ([series.groupby] if series.groupby else []),
|
|
150
168
|
"metrics": [_metric_payload(m, spec.dashboard.slug, chart.name) for m in series.metrics],
|
|
@@ -157,7 +175,8 @@ def smoke_chart(chart, spec: DashboardSpec, resolution: Resolution, client: Supe
|
|
|
157
175
|
ds = resolution.for_chart(chart.dataset)
|
|
158
176
|
ctx = {
|
|
159
177
|
"datasource": {"id": ds.id, "type": "table"},
|
|
160
|
-
"queries":
|
|
178
|
+
"queries": [_with_time_range(q, chart, ds) for q in (
|
|
179
|
+
_mixed_queries(chart, spec, ds) if chart.type == "mixed" else [_query_for(chart, spec)])],
|
|
161
180
|
"result_format": "json",
|
|
162
181
|
"result_type": "full",
|
|
163
182
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""A chart's time range, its own `time_range` or a dashboard time filter's,
|
|
2
|
+
reaches its query only through a time binding.
|
|
3
|
+
|
|
4
|
+
A chart without a time axis binds the dataset's main time column as
|
|
5
|
+
granularity_sqla. A chart drawn on a time axis (timeseries_*, big_number_trend,
|
|
6
|
+
mixed on a time column) gets an adhoc TEMPORAL_RANGE filter on that axis. Before
|
|
7
|
+
that filter (0.2.0), Superset 4.1.4, 5.0.0 and 6.1.0 applied neither the chart's
|
|
8
|
+
time_range nor the dashboard's time filter to those charts (seen live: a line
|
|
9
|
+
chart with time_range "2004-03-01 : 2005-03-02" still drew 2003 to 2005)."""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import zipfile
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from chartwright.compiler import compile_bundle
|
|
18
|
+
from chartwright.dashdiff import _normalize
|
|
19
|
+
from chartwright.decompile import decompile_bundle
|
|
20
|
+
from chartwright.smoke import _mixed_queries, _query_for, _with_time_range
|
|
21
|
+
from chartwright.spec import load_spec
|
|
22
|
+
from chartwright.testing import stub_resolution
|
|
23
|
+
|
|
24
|
+
DS = {"database": "examples", "table": "t"}
|
|
25
|
+
RANGE = "2004-03-01 : 2005-03-02"
|
|
26
|
+
# The charts that take their own time_range.
|
|
27
|
+
RANGED = [
|
|
28
|
+
{"name": "Line", "type": "timeseries_line", "metrics": ["COUNT(*)"], "time_column": "ts"},
|
|
29
|
+
{"name": "Bars", "type": "timeseries_bar", "metrics": ["COUNT(*)"], "time_column": "ts"},
|
|
30
|
+
{"name": "Area", "type": "timeseries_area", "metrics": ["COUNT(*)"], "time_column": "ts"},
|
|
31
|
+
{"name": "Dots", "type": "timeseries_scatter", "metrics": ["COUNT(*)"], "time_column": "ts"},
|
|
32
|
+
{"name": "Mixed by month", "type": "mixed", "x_column": "ts", "time_grain": "P1M",
|
|
33
|
+
"a": {"metrics": ["COUNT(*)"]},
|
|
34
|
+
"b": {"metrics": ["SUM(amount)"], "kind": "line", "axis": "secondary"}},
|
|
35
|
+
]
|
|
36
|
+
TREND = {"name": "Trend", "type": "big_number_trend", "metric": "COUNT(*)", "time_column": "ts"}
|
|
37
|
+
MIXED_CATEGORY = {"name": "Mixed by region", "type": "mixed", "x_column": "region",
|
|
38
|
+
"a": {"metrics": ["COUNT(*)"]},
|
|
39
|
+
"b": {"metrics": ["SUM(amount)"], "kind": "line", "axis": "secondary"}}
|
|
40
|
+
AXIS_NAMES = {c["name"] for c in RANGED} | {TREND["name"]}
|
|
41
|
+
NO_AXIS = [
|
|
42
|
+
{"name": "Total", "type": "big_number_total", "metric": "COUNT(*)"},
|
|
43
|
+
{"name": "Table", "type": "table", "groupby": ["region"], "metrics": ["COUNT(*)"]},
|
|
44
|
+
{"name": "Pie", "type": "pie", "metric": "COUNT(*)", "groupby": "region"},
|
|
45
|
+
{"name": "Pivot", "type": "pivot_table", "rows": ["region"], "columns": [], "metrics": ["COUNT(*)"]},
|
|
46
|
+
{"name": "Bar", "type": "bar", "x_column": "region", "metrics": ["COUNT(*)"]},
|
|
47
|
+
MIXED_CATEGORY,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _spec(charts, time_range=None):
|
|
52
|
+
# Only the charts that have a time_range field take one.
|
|
53
|
+
takes_range = {"timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter", "mixed"}
|
|
54
|
+
charts = [{**c, "dataset": DS,
|
|
55
|
+
**({"time_range": time_range} if time_range and c["type"] in takes_range else {})}
|
|
56
|
+
for c in charts]
|
|
57
|
+
return load_spec({
|
|
58
|
+
"spec_version": "1",
|
|
59
|
+
"dashboard": {"title": "T", "slug": "sdc-t"},
|
|
60
|
+
"charts": charts,
|
|
61
|
+
"layout": {"rows": [[c["name"]] for c in charts]},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolution(spec, main_dttm_col="ts"):
|
|
66
|
+
res = stub_resolution(spec)
|
|
67
|
+
for ds in res.datasets.values():
|
|
68
|
+
ds.main_dttm_col = main_dttm_col
|
|
69
|
+
ds.column_types.update({"ts": 2, "region": 1})
|
|
70
|
+
return res
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bundle(spec, res=None):
|
|
74
|
+
return compile_bundle(spec, res or _resolution(spec))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _params(spec, res=None):
|
|
78
|
+
zf = zipfile.ZipFile(io.BytesIO(_bundle(spec, res)))
|
|
79
|
+
return {cy["slice_name"]: cy["params"] for cy in
|
|
80
|
+
(yaml.safe_load(zf.read(n)) for n in zf.namelist() if "/charts/" in n)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _temporal(params, key="adhoc_filters"):
|
|
84
|
+
return [f for f in params.get(key) or [] if f.get("operator") == "TEMPORAL_RANGE"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _lookup(spec, res):
|
|
88
|
+
by_uuid = {res.for_chart(c.dataset).uuid: dict(DS, schema=None) for c in spec.charts}
|
|
89
|
+
return lambda u: by_uuid.get(u)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("time_range", [RANGE, None])
|
|
93
|
+
def test_a_time_axis_chart_filters_its_axis_with_its_range(time_range):
|
|
94
|
+
params = _params(_spec(RANGED, time_range))
|
|
95
|
+
for name, p in params.items():
|
|
96
|
+
expected = time_range or "No filter"
|
|
97
|
+
assert p["time_range"] == expected, name
|
|
98
|
+
assert _temporal(p) == [{"clause": "WHERE", "expressionType": "SIMPLE", "subject": "ts",
|
|
99
|
+
"operator": "TEMPORAL_RANGE", "comparator": expected}], name
|
|
100
|
+
assert "granularity_sqla" not in p, name # it would replace the axis column
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_trendline_kpi_filters_its_axis():
|
|
104
|
+
"""big_number_trend has no time_range of its own: the filter carries "No filter",
|
|
105
|
+
which the dashboard time filter's range overwrites at query time."""
|
|
106
|
+
p = _params(_spec([TREND]))["Trend"]
|
|
107
|
+
assert p["time_range"] == "No filter"
|
|
108
|
+
assert _temporal(p) == [{"clause": "WHERE", "expressionType": "SIMPLE", "subject": "ts",
|
|
109
|
+
"operator": "TEMPORAL_RANGE", "comparator": "No filter"}]
|
|
110
|
+
assert "granularity_sqla" not in p
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_mixed_time_chart_filters_both_queries():
|
|
114
|
+
p = _params(_spec(RANGED, RANGE))["Mixed by month"]
|
|
115
|
+
assert p["adhoc_filters_b"] == p["adhoc_filters"] and _temporal(p, "adhoc_filters_b")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_mixed_time_chart_without_column_types_follows_its_grain():
|
|
119
|
+
"""The stub resolution reports no column types: a time_grain makes the axis a
|
|
120
|
+
time axis (mixed_time_axis), and the filter follows it."""
|
|
121
|
+
spec = _spec(RANGED[-1:], RANGE)
|
|
122
|
+
p = _params(spec, stub_resolution(spec))["Mixed by month"]
|
|
123
|
+
assert _temporal(p) and _temporal(p, "adhoc_filters_b")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_the_range_filter_follows_the_charts_own_filters():
|
|
127
|
+
chart = {**RANGED[0], "filters": [{"column": "region", "op": "==", "value": "West"}]}
|
|
128
|
+
p = _params(_spec([chart], RANGE))["Line"]
|
|
129
|
+
assert [f["operator"] for f in p["adhoc_filters"]] == ["==", "TEMPORAL_RANGE"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize("time_range", [RANGE, None])
|
|
133
|
+
def test_a_chart_without_a_time_axis_keeps_the_granularity_binding(time_range):
|
|
134
|
+
for name, p in _params(_spec(NO_AXIS, time_range)).items():
|
|
135
|
+
expected = time_range if name == MIXED_CATEGORY["name"] and time_range else "No filter"
|
|
136
|
+
assert p["time_range"] == expected, name
|
|
137
|
+
assert p["granularity_sqla"] == "ts", name
|
|
138
|
+
assert not _temporal(p) and not _temporal(p, "adhoc_filters_b"), name
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_a_dataset_without_a_time_column_binds_nothing_to_a_chart_without_an_axis():
|
|
142
|
+
spec = _spec(NO_AXIS[:1])
|
|
143
|
+
p = _params(spec, _resolution(spec, main_dttm_col=None))["Total"]
|
|
144
|
+
assert "granularity_sqla" not in p and not _temporal(p)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_compiles_are_deterministic():
|
|
148
|
+
spec = _spec(RANGED + [TREND] + NO_AXIS, RANGE)
|
|
149
|
+
assert _bundle(spec) == _bundle(spec)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize("time_range", [RANGE, None])
|
|
153
|
+
def test_decompile_reads_the_range_back_without_a_chart_filter(time_range):
|
|
154
|
+
spec = _spec(RANGED + [TREND] + NO_AXIS, time_range)
|
|
155
|
+
res = _resolution(spec)
|
|
156
|
+
result = decompile_bundle(_bundle(spec, res), _lookup(spec, res))
|
|
157
|
+
assert result.losses == [], [loss.as_dict() for loss in result.losses]
|
|
158
|
+
ranged = {c.name for c in spec.charts if getattr(c, "time_range", None)}
|
|
159
|
+
for chart in result.spec["charts"]:
|
|
160
|
+
assert chart.get("time_range") == (time_range if chart["name"] in ranged else None), chart["name"]
|
|
161
|
+
assert "filters" not in chart, chart["name"]
|
|
162
|
+
# plan compares exactly this: no drift on an unchanged spec.
|
|
163
|
+
assert _normalize(load_spec(result.spec)) == _normalize(spec)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_decompile_keeps_the_charts_own_filters_beside_the_range():
|
|
167
|
+
chart = {**RANGED[-1], "filters": [{"column": "region", "op": "==", "value": "West"}]}
|
|
168
|
+
spec = _spec([chart], RANGE)
|
|
169
|
+
res = _resolution(spec)
|
|
170
|
+
result = decompile_bundle(_bundle(spec, res), _lookup(spec, res))
|
|
171
|
+
assert result.losses == [], [loss.as_dict() for loss in result.losses]
|
|
172
|
+
assert result.spec["charts"][0]["filters"] == [{"column": "region", "op": "==", "value": "West"}]
|
|
173
|
+
assert _normalize(load_spec(result.spec)) == _normalize(spec)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_smoke_queries_with_the_charts_range():
|
|
177
|
+
spec = _spec(RANGED + NO_AXIS, RANGE)
|
|
178
|
+
res = _resolution(spec)
|
|
179
|
+
for chart in spec.charts:
|
|
180
|
+
ds = res.for_chart(chart.dataset)
|
|
181
|
+
queries = _mixed_queries(chart, spec, ds) if chart.type == "mixed" else [_query_for(chart, spec)]
|
|
182
|
+
for q in (_with_time_range(q, chart, ds) for q in queries):
|
|
183
|
+
temporal = [f for f in q["filters"] if f["op"] == "TEMPORAL_RANGE"]
|
|
184
|
+
if chart.name in AXIS_NAMES:
|
|
185
|
+
assert q["time_range"] == RANGE, chart.name
|
|
186
|
+
assert temporal == [{"col": "ts", "op": "TEMPORAL_RANGE", "val": RANGE}], chart.name
|
|
187
|
+
assert "granularity" not in q, chart.name
|
|
188
|
+
elif chart.name == MIXED_CATEGORY["name"]:
|
|
189
|
+
assert q["time_range"] == RANGE and q["granularity"] == "ts" and not temporal
|
|
190
|
+
else: # no time_range of its own: the unfiltered query
|
|
191
|
+
assert q["time_range"] == "No filter" and "granularity" not in q and not temporal
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_smoke_without_a_range_stays_unfiltered():
|
|
195
|
+
spec = _spec(RANGED[:1] + [TREND] + NO_AXIS[:1])
|
|
196
|
+
res = _resolution(spec)
|
|
197
|
+
for chart in spec.charts:
|
|
198
|
+
q = _with_time_range(_query_for(chart, spec), chart, res.for_chart(chart.dataset))
|
|
199
|
+
assert q["time_range"] == "No filter" and "granularity" not in q
|
|
200
|
+
assert not [f for f in q["filters"] if f["op"] == "TEMPORAL_RANGE"]
|
|
@@ -30,7 +30,11 @@ def test_compile_is_byte_stable():
|
|
|
30
30
|
def test_golden_bytes():
|
|
31
31
|
"""The whole guarantee in one assert. Regenerate deliberately with:
|
|
32
32
|
.venv/bin/python -c "from tests.test_compiler import regen; regen()"
|
|
33
|
-
and review the diff.
|
|
33
|
+
and review the diff.
|
|
34
|
+
|
|
35
|
+
Last regenerated for 0.2.1: "Sales Over Time" gained the TEMPORAL_RANGE adhoc
|
|
36
|
+
filter on its time axis, so its own and the dashboard's time range apply
|
|
37
|
+
(tests/test_chart_time_range.py). Every other member is byte-identical."""
|
|
34
38
|
assert GOLDEN.exists(), "golden missing; run regen() and commit the result"
|
|
35
39
|
assert _bundle() == GOLDEN.read_bytes()
|
|
36
40
|
|
|
@@ -66,7 +66,9 @@ def test_compile_time_axis_mixed():
|
|
|
66
66
|
assert (p["seriesType"], p["seriesTypeB"]) == ("bar", "line")
|
|
67
67
|
assert (p["yAxisIndex"], p["yAxisIndexB"]) == (0, 1)
|
|
68
68
|
assert p["y_axis_format"] == "SMART_NUMBER" and p["y_axis_format_secondary"] == ".3f"
|
|
69
|
-
|
|
69
|
+
# The chart's own filter, then the time-range filter on its axis (0.2.1).
|
|
70
|
+
assert p["adhoc_filters"] == p["adhoc_filters_b"] and len(p["adhoc_filters"]) == 2
|
|
71
|
+
assert p["adhoc_filters"][1]["operator"] == "TEMPORAL_RANGE"
|
|
70
72
|
assert p["groupby"] == [] and p["groupby_b"] == []
|
|
71
73
|
|
|
72
74
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|