diff --git a/Architecture.md b/Architecture.md
index ebe91b7..91065b0 100644
--- a/Architecture.md
+++ b/Architecture.md
@@ -706,6 +706,7 @@ fig.add_corr_heatmap(columns=["a", "b"], color_scale="RdBu", color_range="auto")
- A figure holds at most one heatmap-like trace (`histogram2d`, `corr_heatmap`, `geo_histogram2d`): each one is opaque and draws its own figure-wide colorbar, so a second one hides the first and duplicates the colorbar. `FigureSpec` checks this when it is built or parsed, so the builder, a decoded spec and every request get the same rule. A heatmap together with non-heatmap traces, such as a line over a `histogram2d`, stays allowed.
- Renderer split:
- Plotly receives `color_scale` in the Plotly spelling that the trace stored. The server rebuilds a decoded spec, so an older lowercase name still renders its scale. With a linear norm, it applies `zmin` / `zmax` only when `color_range` is fixed.
+ - Plotly dark mode: the client draws `"Viridis"` without its three darkest stops (see [Theme and light/dark mode](#theme-and-lightdark-mode)). Other scales do not change.
- Plotly log norm: Plotly has no log color axis, so the client colors by `log10(z)`, pins the color range in log10 space and labels the colorbar ticks with round data values. Only the colors change: the hover shows the raw values, and a cell at or below 0, which has no log, has no color but keeps its hover and its selection. The transform (`applyLogColorNorm` in `plotly/traces.js`) runs when a trace is built from its template, so it covers every delta, from the server or the cube.
### Dtype-Aware Filtering
@@ -1513,7 +1514,8 @@ bundle composition (which sources go into `shared` / `plotly`) is declared in
```
adapters/js/
-├── theme.css ← CSS custom properties (design tokens)
+├── theme.css ← CSS custom properties (design tokens), light and dark
+├── theme-mode.js ← light/dark mode, inlined in
by page_head_html()
├── toolbar.css ← toolbar and header styles
├── toolbar.js ← shared toolbar hooks + state helpers
├── gridstack-bridge.js ← GridStack.init() IIFE + change/resizestop handlers
@@ -1529,6 +1531,7 @@ adapters/js/
│ └── hover.js ← linked-hover dispatch
└── plotly/
├── traces.js ← configsByFig, trace template builders
+ ├── theme.js ← Plotly layout template from the tokens, fvApplyTheme
├── render.js ← _fvRenderFigure, axis helpers, lock/capture
├── events.js ← relayout/selected/deselect/click handlers
├── hover.js ← Plotly crosshair helpers
@@ -1537,12 +1540,27 @@ adapters/js/
`runtime.py` assembles two bundles from these sources at import: `shared` (panel.js +
runtime/*.js + toolbar.js) and `plotly` (plotly/*.js); `theme.css`
-and `gridstack-bridge.js` are served verbatim.
+and `gridstack-bridge.js` are served verbatim, and `page_head_html()` inlines
+`theme-mode.js`.
Each adapter's `
diff --git a/flexviz/adapters/runtime.py b/flexviz/adapters/runtime.py
index 0e661fb..060a554 100644
--- a/flexviz/adapters/runtime.py
+++ b/flexviz/adapters/runtime.py
@@ -39,6 +39,7 @@
_PLOTLY_SOURCES: list[str] = [
"plotly/traces.js",
+ "plotly/theme.js",
"plotly/render.js",
"plotly/events.js",
"plotly/hover.js",
@@ -76,7 +77,8 @@ def _concat(sources: list[str]) -> str:
_FAVICON_B64 = _b64("favicon.png")
_PAGE_HEAD_HTML = (
"FlexViz \n"
- f' '
+ f' \n'
+ f" "
)
@@ -119,7 +121,10 @@ def plotly_bundle_js() -> str:
def page_head_html() -> str:
- """Return the page title and inline favicon link for a dashboard page.
+ """Return the page title, inline favicon link and mode script for a page.
+
+ The mode script sets light or dark mode on ```` before the first
+ paint, so a dark page never flashes light.
An inline icon stops the browser probing ``GET /favicon.ico``, which 404s
under ``/view`` and under a mount prefix, where the browser asks the host
diff --git a/tests/test_adapters.py b/tests/test_adapters.py
index 929022f..81b1a10 100644
--- a/tests/test_adapters.py
+++ b/tests/test_adapters.py
@@ -6,6 +6,8 @@
from __future__ import annotations
+import re
+
import pytest
from flexviz.adapters.base import AbstractAdapter
@@ -118,7 +120,7 @@ def test_toolbar_html_omits_empty_group(self):
assert "fv-btn-import" not in html
assert "fv-btn-reset" in html
- def test_toolbar_html_all_hidden_renders_empty_toolbar(self):
+ def test_toolbar_html_all_hidden_keeps_only_the_mode_button(self):
tc = ToolbarConfig(
show_reset=False,
show_deselect=False,
@@ -131,7 +133,8 @@ def test_toolbar_html_all_hidden_renders_empty_toolbar(self):
show_import=False,
)
html = AbstractAdapter._toolbar_html(tc)
- assert "fv-btn-" not in html
+ # The light/dark mode is a viewer preference, not a dashboard option.
+ assert re.findall(r'id="(fv-btn-[\w-]+)"', html) == ["fv-btn-mode"]
assert "fv-header" in html
def test_toolbar_config_roundtrips_via_layout_spec(self):
diff --git a/tests/test_browser.py b/tests/test_browser.py
index 80e0be2..d5219f9 100644
--- a/tests/test_browser.py
+++ b/tests/test_browser.py
@@ -755,7 +755,7 @@ def test_open_renders_each_figure_once(self, page: Page, server_port: int):
url = _dashboard_url(server_port, n_figures=2)
page.add_init_script(_COUNT_RENDERS_JS)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
renders = page.evaluate("() => window.__renders")
assert len(renders) == 2, (
@@ -774,7 +774,7 @@ def test_open_draws_once_per_figure_after_the_request(
url = _dashboard_url(server_port, n_figures=2)
page.add_init_script(_TRACE_DRAWS_JS)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
draws = page.evaluate("() => window.__draws")
sent_at = page.evaluate("() => window.__updateSentAt")
@@ -805,7 +805,7 @@ def test_saved_viewport_is_applied_on_open(self, page: Page, server_port: int):
)
page.add_init_script(_COUNT_RENDERS_JS)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
applied = page.evaluate("""() => {
const gd = document.querySelector('.js-plotly-plot');
@@ -840,7 +840,7 @@ def test_plotly_control_bar_renders_below_plot_with_a11y_labels(
):
url = _dashboard_url(server_port, n_figures=1)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
geometry = page.evaluate("""() => {
const panel = document.querySelector('fv-panel');
@@ -878,7 +878,7 @@ def test_plotly_control_bar_renders_below_plot_with_a11y_labels(
def test_plotly_static_figure_omits_control_bar(self, page: Page, server_port: int):
page.goto(_dashboard_url_static_pie(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
assert page.locator(".fv-panel-bar").count() == 0
def test_mode_toggle_click_does_not_post_dashboard_update(
@@ -896,7 +896,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
count_before = len(update_bodies)
page.click("#fv-bar-0 .fv-mode-btn[data-mode='pan']")
@@ -1069,7 +1069,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_timeout(500)
before = len(update_requests)
@@ -1094,7 +1094,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Selection sourced from figure 1, filtering figure 0 (the target).
page.evaluate("""() => {
@@ -1189,7 +1189,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = len(update_requests)
page.evaluate(
@@ -1218,7 +1218,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = len(update_requests)
page.evaluate(
@@ -1241,7 +1241,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = len(update_requests)
page.evaluate(
@@ -1258,7 +1258,7 @@ def test_filter_summary_strip_shows_global_chip_and_source_panel_echo(
):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -1297,7 +1297,7 @@ def test_filter_chip_remove_clears_only_its_source_selection(
):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUids = DASHBOARD_SPEC.figures.map(f => f.uid);
@@ -1349,7 +1349,7 @@ def test_filter_summary_joins_multi_clause_and_or_predicates(
):
url = _dashboard_url(server_port, n_figures=1)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -1463,7 +1463,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.locator("#fv-bar-0 .fv-mode-btn[data-mode='select']").click()
page.wait_for_timeout(300)
@@ -1498,7 +1498,7 @@ def test_bar_brush_selection_keeps_plotly_selection_box(
):
url = _dashboard_url_plotly_selection_box(server_port, "bar")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-0')?.data?.[0]?.x?.length > 0"
)
@@ -1547,7 +1547,7 @@ def test_histogram_brush_selection_keeps_plotly_selection_box(
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-0')?.data?.[0]?.x?.length > 0"
)
@@ -1660,7 +1660,7 @@ def capture_request(req: PWRequest) -> None:
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -1742,7 +1742,7 @@ def test_geo_histogram_keeps_zoom_pan_mode_enabled(
):
url = _dashboard_url_geo(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
mode_state = page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -1790,7 +1790,7 @@ def capture_request(req: PWRequest) -> None:
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before_line_count = page.evaluate("""() => {
const lineUid = DASHBOARD_SPEC.figures[1].uid;
@@ -1898,7 +1898,7 @@ def test_grouped_line_viewport_removes_hidden_child(
):
url = _dashboard_url_grouped(server_port, trace_type="line")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""async () => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -1925,7 +1925,7 @@ def test_grouped_bar_renders_children(self, page: Page, server_port: int):
# ---------------------------------------------------------------------------
-def _wait_for_init(page: Page, renderer: str) -> None:
+def _wait_for_init(page: Page) -> None:
"""Wait until the initial data load has populated the charts."""
page.wait_for_selector(".js-plotly-plot", timeout=15_000)
# Allow the init POST round-trip to complete.
@@ -1948,7 +1948,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
initial_count = len(update_bodies)
# Trigger a selection on figure A via JS.
@@ -1986,7 +1986,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Select on A, then deselect via toolbar.
page.evaluate("""() => {
@@ -2032,7 +2032,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUids = DASHBOARD_SPEC.figures.map(f => f.uid);
@@ -2070,7 +2070,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Inject a selection sourced from figure 0.
page.evaluate("""() => {
@@ -2131,7 +2131,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Selection sourced from figure 0, filtering figures 1 and 2; plus a
# viewport on figure 1 so its reset actually clears something (otherwise
@@ -2193,7 +2193,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Select on A first.
page.evaluate("""() => {
@@ -2244,7 +2244,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUids = DASHBOARD_SPEC.figures.map(f => f.uid);
@@ -2285,7 +2285,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUids = DASHBOARD_SPEC.figures.map(f => f.uid);
@@ -2320,7 +2320,7 @@ def test_overlay_heatmap_shows_single_colorbar(self, page: Page, server_port: in
"""Overlay cross-filter on histogram2d must expose only the fg colorbar."""
url = _dashboard_url_hist2d_overlay(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-btn-cfmode")
page.wait_for_timeout(500)
@@ -2403,7 +2403,7 @@ def test_overlay_map_fades_background_under_filtered_layer(
),
)
page.goto(_dashboard_url_geo_overlay(server_port, **geo_kwargs))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-btn-cfmode")
# A short range, so the background has cells without filtered rows.
@@ -2464,7 +2464,7 @@ def test_overlay_reuses_same_color_and_mutes_background(
):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUids = DASHBOARD_SPEC.figures.map(f => f.uid);
@@ -2506,7 +2506,7 @@ def test_overlay_reset_clears_fg_and_restores_full_opacity(
and the bg trace must return to full opacity (1.0)."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# 1. Enable overlay mode.
page.click("#fv-btn-cfmode")
@@ -2566,7 +2566,7 @@ def test_overlay_reset_then_new_selection_shows_correct_overlay(
must show bg at low opacity and fg at full opacity again."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# 1. Enable overlay mode, make a selection, then reset.
page.click("#fv-btn-cfmode")
@@ -2624,7 +2624,7 @@ def test_multi_histogram_overlay_offsets_by_logical_trace(
):
url = _dashboard_url_line_multi_hist_target(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
DASHBOARD_SPEC.state.cross_filter_mode = 'overlay';
@@ -2687,7 +2687,7 @@ def test_multi_histogram_no_selection_uses_group_barmode(
):
url = _dashboard_url_line_multi_hist_target(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
barmode = page.evaluate("""() => {
const fig = document.querySelectorAll('.js-plotly-plot')[1];
@@ -2704,7 +2704,7 @@ def test_hist_target_reset_and_deselect_clear_fg_without_selector_errors(
console_messages: list[str] = []
page.on("console", lambda msg: console_messages.append(msg.text))
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-btn-cfmode")
page.wait_for_timeout(1_000)
@@ -2789,7 +2789,7 @@ def test_grouped_bar_target_reset_clears_fg_without_selector_errors(
console_messages: list[str] = []
page.on("console", lambda msg: console_messages.append(msg.text))
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-btn-cfmode")
page.wait_for_timeout(1_000)
@@ -2889,7 +2889,7 @@ class TestShareUrlState:
def test_share_preserves_zoom(self, page: Page, server_port: int):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Programmatically set a viewport state.
page.evaluate("""() => {
@@ -2923,7 +2923,7 @@ def handle_dialog(dialog):
share_url = share_resp
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
restored_vp = page.evaluate("DASHBOARD_SPEC.state.viewport")
assert restored_vp, "Viewport state must be restored from shared URL"
@@ -2935,7 +2935,7 @@ def handle_dialog(dialog):
def test_share_preserves_cross_filter(self, page: Page, server_port: int):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Programmatically add a selection.
page.evaluate("""() => {
@@ -2958,7 +2958,7 @@ def test_share_preserves_cross_filter(self, page: Page, server_port: int):
assert share_url
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
restored_sels = page.evaluate("DASHBOARD_SPEC.state.selections")
assert len(restored_sels) >= 1, "Selections must be restored from shared URL"
@@ -2970,7 +2970,7 @@ def test_share_preserves_cross_filter_effect_on_data(
"""End-to-end: A selection on A must still narrow B after Share URL round-trip."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# On the first page: capture B's init count, apply a selection on A, and
# verify that B's visible data is narrowed. Then obtain a Share URL via
@@ -3022,7 +3022,7 @@ def test_share_preserves_cross_filter_effect_on_data(
# Now open the shared URL and assert that B remains narrowed.
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
restored_count_b = page.evaluate(
"""() => {
@@ -3042,7 +3042,7 @@ def test_share_preserves_cross_filter_effect_on_data(
def test_share_preserves_zoom_and_filter(self, page: Page, server_port: int):
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Set both viewport and selection state.
page.evaluate("""() => {
@@ -3065,7 +3065,7 @@ def test_share_preserves_zoom_and_filter(self, page: Page, server_port: int):
assert share_url
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
restored_vp = page.evaluate("DASHBOARD_SPEC.state.viewport")
restored_sels = page.evaluate("DASHBOARD_SPEC.state.selections")
@@ -3077,7 +3077,7 @@ def test_share_draws_selection_boxes(self, page: Page, server_port: int):
"""Share URL must restore visible selection boxes on the source figure."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Programmatically add a selection with both x and y ranges so that
# a rectangular selection box can be drawn by the renderer.
@@ -3106,7 +3106,7 @@ def test_share_draws_selection_boxes(self, page: Page, server_port: int):
# Open the shared URL and assert that a selection box is present
# on the source figure.
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
has_box = page.evaluate(
"""() => {
@@ -3129,7 +3129,7 @@ def test_share_preserves_zoomed_aggregation_and_cross_filter(
"""Repro: zoom A + select A, then Share → reload must apply both."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
share_url = page.evaluate(
"""async () => {
@@ -3169,7 +3169,7 @@ def test_share_preserves_zoomed_aggregation_and_cross_filter(
assert share_url
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
restored = page.evaluate(
"""() => {
@@ -3216,7 +3216,7 @@ def test_brush_visible_via_flexviz_state_and_share_decode(
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.locator("#fv-bar-0 .fv-mode-btn[data-mode='select']").click()
page.wait_for_timeout(300)
@@ -3253,7 +3253,7 @@ def test_zoom_viewport_visible_via_flexviz_state(
):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Default mode is zoom: a drag zooms and must land in the viewport.
drag_layer = page.locator("#fv-plot-0 .nsewdrag")
@@ -3275,7 +3275,7 @@ def test_flexviz_state_returns_detached_snapshot(
):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
original_uid = page.evaluate("DASHBOARD_SPEC.figures[0].uid")
mutated_view = page.evaluate("""() => {
@@ -3291,7 +3291,7 @@ def test_flexviz_state_returns_detached_snapshot(
def test_compact_state_keys_and_revision_bump(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.locator("#fv-bar-0 .fv-mode-btn[data-mode='select']").click()
page.wait_for_timeout(300)
@@ -3324,7 +3324,7 @@ def test_apply_clears_selection_and_leaves_figures_untouched(
):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
total = "divs[1].data[0].y.reduce((a, b) => a + b, 0)"
page.wait_for_function(f"() => ({total}) > 0")
@@ -3352,7 +3352,7 @@ def test_apply_clears_selection_and_leaves_figures_untouched(
def test_apply_partial_state_keeps_viewport(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Default mode is zoom: this drag stores a viewport range.
drag_layer = page.locator("#fv-plot-0 .nsewdrag")
@@ -3377,7 +3377,7 @@ def test_apply_partial_state_keeps_viewport(self, page: Page, server_port: int):
def test_apply_rejects_when_the_update_fails(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.route("**/dashboard/update", lambda route: route.fulfill(status=500))
# `force_update` bypasses the client cache, so the route is really hit.
@@ -3392,7 +3392,7 @@ def test_apply_rolls_back_a_rejected_patch(self, page: Page, server_port: int):
"""A patch the server rejects leaves spec, data and later gestures working."""
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
state_before = page.evaluate("window.flexvizState().state")
sizes_before = page.evaluate(self._LAYER_SIZES)
assert all(sizes_before), sizes_before
@@ -3420,7 +3420,7 @@ def test_apply_rolls_back_a_rejected_patch(self, page: Page, server_port: int):
def test_apply_says_when_the_rollback_fails_too(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.route("**/dashboard/update", lambda route: route.fulfill(status=500))
message = page.evaluate("""() => window.flexvizApply({state: {selections: []}})
@@ -3437,7 +3437,7 @@ def test_apply_rolls_back_when_the_second_request_fails(
selection). A failure on the second one still restores the old page."""
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
state_before = page.evaluate("window.flexvizState().state")
sizes_before = page.evaluate(self._LAYER_SIZES)
@@ -3465,7 +3465,7 @@ def test_apply_stops_after_a_failed_init(self, page: Page, server_port: int):
reason and the rollback follows at once."""
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
event_types: list[str] = []
@@ -3488,7 +3488,7 @@ def test_apply_updates_the_lock_controls(self, page: Page, server_port: int):
"""An applied lock shows on the panel button and leaves zoom mode."""
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3528,7 +3528,7 @@ def test_failed_first_load_logs_an_error(self, page: Page, server_port: int):
def test_import_failure_labels_the_button(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
btn = page.locator("#fv-btn-import")
page.evaluate("""() => window.fvOnImport(
@@ -3555,7 +3555,7 @@ def test_import_failure_labels_the_button(self, page: Page, server_port: int):
def test_apply_ignores_the_keys_it_cannot_apply(self, page: Page, server_port: int):
url = _dashboard_url_selection_duplicate_repro(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
panels = page.locator(".js-plotly-plot").count()
assert panels > 1
@@ -3649,7 +3649,7 @@ def test_share_roundtrip_through_prefix_stripping_proxy(
):
base = f"http://127.0.0.1:{proxied_port}"
page.goto(f"{base}/demo4")
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Capture what the Share button hands to the clipboard.
page.evaluate("""() => {
@@ -3677,7 +3677,7 @@ def test_share_roundtrip_through_prefix_stripping_proxy(
),
)
page.goto(shared)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
assert update_statuses, "Shared page must fire /dashboard/update on init"
for url, status in update_statuses:
@@ -3745,7 +3745,7 @@ def test_hover_button_present_and_inactive_by_default(
):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
btn = page.query_selector("#fv-hover-btn")
assert btn is not None, "Hover dropdown button must be present"
@@ -3762,7 +3762,7 @@ def test_hover_button_present_and_inactive_by_default(
def test_hover_toggle_turns_on(self, page: Page, server_port: int):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Clicking the toggle turns linked hover on.
page.click("#fv-hover-btn")
@@ -3781,7 +3781,7 @@ def test_hover_toggle_turns_on(self, page: Page, server_port: int):
def test_hover_toggle_turns_off(self, page: Page, server_port: int):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Toggle on, then off.
page.click("#fv-hover-btn")
@@ -3798,7 +3798,7 @@ def test_hover_toggle_turns_off(self, page: Page, server_port: int):
def test_hover_targets_built_at_load(self, page: Page, server_port: int):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
targets = page.evaluate("hoverTargetsByColumn")
assert targets is not None, "hoverTargetsByColumn must be defined"
@@ -3811,7 +3811,7 @@ def test_plotly_hover_shows_crosshair_on_other_figure(
"""Axis mode hover must add a guide to the other figure."""
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
guides_after = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3839,7 +3839,7 @@ def test_plotly_hover_shows_crosshair_on_other_figure(
def test_plotly_unhover_clears_crosshairs(self, page: Page, server_port: int):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
guides_after = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3863,7 +3863,7 @@ def test_plotly_axis_hover_emits_crosshair_on_shared_axes(
# and one y-guide.
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
linked_shape = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3892,7 +3892,7 @@ def test_plotly_axis_hover_emits_no_visual_to_source_figure(
"""Spec rule: no linked visual is emitted to the source figure."""
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
source_guides = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3910,7 +3910,7 @@ def test_plotly_axis_hover_emits_no_visual_to_source_figure(
def test_hover_no_visual_when_off(self, page: Page, server_port: int):
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
guides_after = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -3930,7 +3930,7 @@ def test_plotly_minmax_hover_links_even_when_not_near_exact_point(
"""Regression: linked hover should work with minmax traces without pixel-perfect point hit."""
url = _dashboard_url_hover_minmax_shared_x(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Enable linked hover via the toggle
page.click("#fv-hover-btn")
@@ -3991,7 +3991,7 @@ def test_hover_mode_persists_through_share(self, page: Page, server_port: int):
"""Toggle hover on, share, reload — client_state.hover_mode must still be 'on'."""
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Toggle linked hover on
page.click("#fv-hover-btn")
@@ -4010,7 +4010,7 @@ def test_hover_mode_persists_through_share(self, page: Page, server_port: int):
assert share_url
page.goto(share_url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
mode = page.evaluate(
"DASHBOARD_SPEC.client_state && DASHBOARD_SPEC.client_state.hover_mode"
@@ -4042,7 +4042,7 @@ def test_hover_dropdown_hidden_for_single_figure_dashboard(
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
wrapper = page.query_selector("#fv-hover-dropdown")
if wrapper:
@@ -4055,7 +4055,7 @@ def test_hover_toggle_aria_pressed_state(self, page: Page, server_port: int):
"""aria-pressed must track the on/off toggle state."""
url = _dashboard_url_hover(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
btn = page.query_selector("#fv-hover-btn")
if btn is None:
@@ -4106,7 +4106,7 @@ def test_temporal_hover_band_lands_on_the_bin_edge_off_utc(
spec = dash.to_spec(source_name="_browser_edges_hist_date")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
base = page.evaluate("""() => {
const traceUid = DASHBOARD_SPEC.figures[1].traces[0].uid;
@@ -4173,7 +4173,7 @@ def build(dash):
source = f"_browser_band_clip_{'log' if log_x else 'linear'}"
page.goto(_color_norm_url(server_port, source, df, build))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate(
"""zoom => Plotly.relayout(
divs[figUidToIdx[DASHBOARD_SPEC.figures[1].uid]], {'xaxis.range': zoom}
@@ -4243,7 +4243,7 @@ def build(dash):
dash.add_figure().add_histogram(x="ts", bins=10)
page.goto(_color_norm_url(server_port, "_browser_date_hover", df, build))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
center = page.evaluate("""() => {
if (!DASHBOARD_SPEC.client_state) DASHBOARD_SPEC.client_state = {};
DASHBOARD_SPEC.client_state.hover_mode = 'axis';
@@ -4318,7 +4318,7 @@ def test_hover_toggle_available_for_hist_dashboard(
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
btn = page.query_selector("#fv-hover-btn")
assert btn is not None, "Hover toggle must be present"
@@ -4355,7 +4355,7 @@ def test_cell_hover_emits_x_band_to_line_target(self, page: Page, server_port: i
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
guides = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -4413,7 +4413,7 @@ def test_cell_hover_no_visual_to_source_figure(self, page: Page, server_port: in
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
source_guides = page.evaluate("""() => {
const fig1Uid = DASHBOARD_SPEC.figures[1].uid;
@@ -4460,7 +4460,7 @@ def test_axis_mode_histogram_source_emits_guide(self, page: Page, server_port: i
spec = dash.to_spec(source_name="_browser_hist_axis_src")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
guides = page.evaluate("""() => {
const lineFigUid = DASHBOARD_SPEC.figures[0].uid;
@@ -4514,7 +4514,7 @@ def test_hover_toggle_hidden_for_geo_only_cell_source(
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
wrapper = page.query_selector("#fv-hover-dropdown")
if wrapper:
@@ -4552,7 +4552,7 @@ def test_cell_hover_uses_target_axis_for_band_orientation(
url = f"http://127.0.0.1:{server_port}/view?spec={encoded}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
emitted = page.evaluate("""() => {
const sourceFigUid = DASHBOARD_SPEC.figures[0].uid;
@@ -4611,7 +4611,7 @@ def test_grouped_histogram_hover_edges_keyed_by_parent(
spec = dash.to_spec(source_name="_browser_grouped_hist_cells")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
edges = page.evaluate("""() => {
const parentUid = DASHBOARD_SPEC.figures[0].traces[0].uid;
@@ -4654,7 +4654,7 @@ def test_axis_hover_line_emits_band_on_grouped_histograms(
spec = dash.to_spec(source_name="_browser_axis_grouped_hist")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate("""() => {
const lineFigUid = DASHBOARD_SPEC.figures[0].uid;
@@ -4712,7 +4712,7 @@ def test_axis_hover_histogram_x_source_emits_y_guide_on_line(
spec = dash.to_spec(source_name="_browser_axis_hist_x_to_line")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
axes = page.evaluate("""() => {
const lineFigUid = DASHBOARD_SPEC.figures[0].uid;
@@ -4763,7 +4763,7 @@ def test_cell_hover_1d_histogram_emits_band_not_cell_on_hist2d(
spec = dash.to_spec(source_name="_browser_cell_1d_to_2d")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Drive the cell projection directly: a 1D cell event (constrains only x)
# whose column maps to the hist2d's y axis must yield a y-band, not a rect.
@@ -4856,7 +4856,7 @@ def test_histogram_edges_and_hover_cell_lookup(self, page: Page, server_port: in
spec = dash.to_spec(source_name="_browser_edges_hist")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
base = page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[1].uid;
@@ -4925,7 +4925,7 @@ def test_histogram2d_edges_and_hover_cell_lookup(
spec = dash.to_spec(source_name="_browser_edges_hist2d")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
base = page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -4983,7 +4983,7 @@ def test_temporal_histogram2d_source_hover_links_the_cell(
spec = dash.to_spec(source_name="_browser_edges_hist2d_date")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
edges = page.evaluate("""() => {
const base = layerDataByUid[DASHBOARD_SPEC.figures[0].traces[0].uid].base;
@@ -5020,7 +5020,7 @@ def test_hover_lookup_puts_edge_values_in_the_server_bin(
spec = dash.to_spec(source_name="_browser_edges_lookup")
url = f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate(
"""(probeValues) => {
@@ -5064,7 +5064,7 @@ def bounds(edges, i):
def test_geo_histogram2d_edges_and_rectangles(self, page: Page, server_port: int):
url = _dashboard_url_geo(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate(r"""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -5149,7 +5149,7 @@ def test_reset_clears_plotly_selection_boxes(self, page: Page, server_port: int)
"""Clicking toolbar Reset must clear Plotly's visual selection rectangle."""
url = _dashboard_url(server_port, n_figures=2)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Inject a cross-filter selection programmatically.
page.evaluate(_INJECT_SELECTION_JS)
@@ -5203,7 +5203,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate(_INJECT_SELECTION_JS)
page.wait_for_timeout(1_500)
@@ -5235,7 +5235,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Inject a cross-filter selection.
page.evaluate(_INJECT_SELECTION_JS)
@@ -5294,7 +5294,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Seed two viewports and a selection sourced from fig A.
page.evaluate("""() => {
@@ -5362,7 +5362,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_selector(
"#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']:not([disabled])"
)
@@ -5426,7 +5426,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = page.evaluate("""() => {
const gd = divs[0];
@@ -5501,7 +5501,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
before = page.evaluate("""() => {
const gd = divs[0];
@@ -5555,7 +5555,7 @@ def test_axis_lock_category_axis_is_visual_only(self, page: Page, server_port: i
"""Category-axis locks are allowed because they no longer affect backend data."""
url = _dashboard_url_plotly_selection_box(server_port, "bar")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
lock_x = "#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']"
page.wait_for_selector(lock_x)
@@ -5593,7 +5593,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Lock ONLY the x axis (the toolbar "lock-axes" button locks every
# lockable axis, which would leave nothing unlocked to persist). We
@@ -5661,7 +5661,7 @@ def capture(req: PWRequest) -> None:
page.on("request", capture)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => {
const figUid = DASHBOARD_SPEC.figures[0].uid;
@@ -5714,7 +5714,7 @@ def test_global_reset_preserves_axis_lock_ranges(
"""Global reset clears viewport/selections but keeps explicit axis locks."""
url = _dashboard_url(server_port, n_figures=1)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']")
page.wait_for_function("""() => {
@@ -5789,7 +5789,7 @@ def test_resets_keep_a_locked_zoomed_axis(self, page: Page, server_port: int):
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
fig_uid = self._zoom_and_lock(page)
assert fig_uid
@@ -5826,7 +5826,7 @@ def test_grouped_line_multi_group_by_hidden_trace_stays_hidden_after_zoom(
):
url = _dashboard_url_grouped_line_multi_group_by(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-0')?.data?.length >= 4"
@@ -5865,7 +5865,7 @@ class TestTreemapClickBrowser:
def test_treemap_click_toggle_keeps_root_view(self, page: Page, server_port: int):
url = _dashboard_url_treemap_with_line(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-1')?.data?.[0]?.ids?.length > 1"
@@ -5923,7 +5923,7 @@ def test_treemap_leaf_selection_filters_with_parent_path(
):
url = _dashboard_url_treemap_pie_selection(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-1')?.data?.[0]?.ids?.includes('root/solar/NL')"
@@ -5969,7 +5969,7 @@ def test_treemap_parent_then_leaf_selection_highlights_leaf_only(
page.set_viewport_size({"width": 1400, "height": 800})
url = _dashboard_url_treemap_pie_selection(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-1')?.data?.[0]?.ids?.includes('root/solar/NL')"
@@ -6096,7 +6096,7 @@ class TestPieClickBrowser:
def test_pie_click_dims_unselected_slices(self, page: Page, server_port: int):
url = _dashboard_url_treemap_pie_selection(server_port)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-2')?.data?.[0]?.labels?.length > 1"
@@ -6565,7 +6565,7 @@ def _open(page: Page, url: str) -> list[dict]:
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
posts.clear()
return posts
@@ -7010,7 +7010,7 @@ def _open(page: Page, url: str) -> list[dict]:
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate(_COUNT_SETTLED_UPDATES)
posts.clear()
return posts
@@ -7355,7 +7355,7 @@ def test_histogram2d_log_colors_raw_hover_and_count_ticks(
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
[[trace]] = page.evaluate(_READ_HEATMAPS_JS)
assert trace["z"] == [[0.0, 1.0, None, pytest.approx(3.0)]]
@@ -7382,7 +7382,7 @@ def test_colorbar_ticks_sit_at_the_values_they_label(
lambda d: d.add_figure().add_histogram2d(x="x", y="y", color_norm="log"),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
ticks = page.evaluate("""() => {
const ticks = (lo, hi) => logColorbarTicks(Math.log10(lo), Math.log10(hi));
@@ -7450,7 +7450,7 @@ def test_uniform_counts_and_narrow_fixed_range_keep_a_readable_colorbar(
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
[[trace]] = page.evaluate(_READ_HEATMAPS_JS)
assert trace["colorbar"]["ticktext"] == ticktext
@@ -7485,7 +7485,7 @@ def build(d):
}
)
page.goto(_color_norm_url(server_port, "_browser_color_norm_hover", df, build))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
[trace] = page.evaluate(_READ_HEATMAPS_JS)[0]
[raw] = trace["text"]
@@ -7527,7 +7527,7 @@ def test_heatmap_without_a_colored_cell_keeps_its_hover(
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
[[trace]] = page.evaluate(_READ_HEATMAPS_JS)
assert trace["colorbar"]["ticktext"] == ["1"]
@@ -7558,7 +7558,7 @@ def test_geo_histogram2d_log_colors_raw_hover_and_step_ticks(
),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
[[trace]] = page.evaluate(_READ_HEATMAPS_JS)
assert sorted(trace["text"]) == [1, 5]
@@ -7594,7 +7594,7 @@ def build(d):
page.goto(
_color_norm_url(server_port, "_browser_color_norm_geo_select", df, build)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_timeout(500)
[[trace]] = page.evaluate(_READ_HEATMAPS_JS)
@@ -7633,7 +7633,7 @@ def test_overlay_cross_filter_keeps_both_layers_in_log_space(
server_port, color_norm="log", color_range=color_range
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.click("#fv-btn-cfmode")
page.evaluate("""() => {
@@ -7736,7 +7736,7 @@ def test_reversed_axis_survives_zoom_and_reset(
),
)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
self._wait_for_full_reversed_range(page)
page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [800, 200]})")
@@ -7784,7 +7784,7 @@ def test_range_given_low_to_high_draws_reversed(
),
)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
self._wait_for_full_reversed_range(page)
page.evaluate(
@@ -7856,7 +7856,7 @@ def test_drag_zoom_keeps_the_viewport_in_data_units(
),
)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
box = page.locator("#fv-plot-0 .nsewdrag").bounding_box()
assert box is not None
@@ -7882,7 +7882,7 @@ def test_far_zoom_out_sends_finite_bounds(self, page: Page, server_port: int):
page.goto(
_color_norm_url(server_port, "_browser_log_axis_far", self._DF, self._build)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# 10 ** 320 is Infinity in a double, which JSON sends as null.
with page.expect_response("**/dashboard/update") as response:
@@ -7902,7 +7902,7 @@ def test_saved_viewport_opens_at_its_range(self, page: Page, server_port: int):
min=100.0, max=10_000.0
)
page.goto(f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}")
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
self._wait_for_data_inside(page, 99, 10_001)
assert self._shown_range(page) == pytest.approx([2, 4])
@@ -7913,7 +7913,7 @@ def test_lock_keeps_its_range_in_data_units(self, page: Page, server_port: int):
server_port, "_browser_log_axis_lock", self._DF, self._build
)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [2, 4]})")
self._wait_for_data_inside(page, 99, 10_001)
@@ -7946,7 +7946,7 @@ def test_selection_band_spans_a_log_count_axis(self, page: Page, server_port: in
),
)
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate("""() => flexvizApply({state: {selections: [{
source_figure_uid: DASHBOARD_SPEC.figures[0].uid,
predicates: [{clauses: [{column: 'x', range: [100000, 500000]}]}],
@@ -7982,7 +7982,7 @@ def build(dash):
).update_layout(xaxis={"type": "log"})
page.goto(_color_norm_url(server_port, "_browser_log_axis_hover", df, build))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate("""() => {
const fig0Uid = DASHBOARD_SPEC.figures[0].uid;
@@ -8004,3 +8004,333 @@ def build(dash):
assert result["guideLeft"] is not None, "Expected an x-guide on the log target"
assert result["guideLeft"] == pytest.approx(result["expected"], abs=0.5)
+
+
+# ---------------------------------------------------------------------------
+# Theme: light and dark mode
+# ---------------------------------------------------------------------------
+
+_OKABE_ITO = ["#0072b2", "#e69f00", "#009e73", "#cc79a7"]
+
+
+def _switch_to_dark(page: Page) -> None:
+ """Click the mode button from Auto (light system) through Light to Dark."""
+ page.click("#fv-btn-mode")
+ page.click("#fv-btn-mode")
+ page.wait_for_function("() => document.documentElement.dataset.fvMode === 'dark'")
+ # The switch redraws every themed figure with the dark template.
+ page.wait_for_function(
+ """() => divs.every((gd, i) =>
+ _fvFigureHasOwnTemplate[i] || gd._fullLayout.paper_bgcolor === '#0b0e12')"""
+ )
+
+
+@pytest.mark.browser
+class TestThemeBrowser:
+ """The mode is a client-only view preference: it changes only the look."""
+
+ def test_mode_switch_sends_no_request_and_keeps_the_state(
+ self, page: Page, server_port: int
+ ):
+ page.emulate_media(color_scheme="light")
+ page.goto(_dashboard_url_grouped(server_port, "line", n_figures=2))
+ _wait_for_init(page)
+ # A zoom that the redraw must keep.
+ page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [10, 40]})")
+ page.wait_for_timeout(1_500)
+ read = """() => ({
+ spec: JSON.stringify([DASHBOARD_SPEC.state, DASHBOARD_SPEC.client_state]),
+ range: divs[0]._fullLayout.xaxis.range,
+ colors: divs.map(gd => gd.data.map(t => t.line.color)),
+ })"""
+ before = page.evaluate(read)
+ requests: list[str] = []
+ page.on(
+ "request",
+ lambda r: requests.append(r.url) if "/dashboard/update" in r.url else None,
+ )
+
+ _switch_to_dark(page)
+ page.evaluate("() => window.fvApplyTheme()")
+ page.wait_for_timeout(1_000)
+
+ assert requests == []
+ assert page.evaluate(read) == before
+ # Group colors keep their hex: group_domains stores it. The zoom on
+ # figure 0 leaves only group A in view.
+ assert before["colors"] == [_OKABE_ITO[:1], _OKABE_ITO[:2]]
+ assert page.inner_text("#fv-btn-mode") == "Mode: Dark"
+ assert page.evaluate("() => localStorage.getItem('fv-mode')") == "dark"
+
+ def test_update_layout_wins_over_the_theme(self, page: Page, server_port: int):
+ df = pl.DataFrame({"x": [float(i) for i in range(50)], "y": [1.0] * 50})
+ font = {"family": "Courier New", "size": 22, "color": "#654321"}
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_layout",
+ df,
+ lambda d: (
+ d.add_figure(title="Power")
+ .add_line(x="x", y="y")
+ .xlabel("Time")
+ .legend(True)
+ .update_layout(plot_bgcolor="#123456", font=font)
+ ),
+ )
+ page.emulate_media(color_scheme="dark")
+ page.goto(url)
+ _wait_for_init(page)
+
+ # The figure font reaches every text, as it does without a template.
+ layout = page.evaluate("""() => {
+ const l = divs[0]._fullLayout;
+ return {
+ colors: [l.plot_bgcolor, l.paper_bgcolor],
+ texts: [l.font, l.xaxis.tickfont, l.legend.font]
+ .map(f => [f.family, f.size, f.color]),
+ titles: [l.title.font, l.xaxis.title.font].map(f => [f.family, f.color]),
+ };
+ }""")
+ assert layout == {
+ "colors": ["#123456", "#0b0e12"],
+ "texts": [["Courier New", 22, "#654321"]] * 3,
+ "titles": [["Courier New", "#654321"]] * 2,
+ }
+
+ def test_series_hover_label_is_neutral_with_a_series_border(
+ self, page: Page, server_port: int
+ ):
+ page.emulate_media(color_scheme="light")
+ page.goto(_dashboard_url_grouped(server_port, "line"))
+ _wait_for_init(page)
+
+ labels = page.evaluate("""() => divs[0]._fullData.map(t => [
+ t.hoverlabel.bgcolor, t.hoverlabel.font.color, t.hoverlabel.bordercolor,
+ ])""")
+ assert labels == [["#0f172a", "#f8fafc", color] for color in _OKABE_ITO[:2]]
+
+ @pytest.mark.parametrize(
+ "layout",
+ [
+ {"hoverlabel": {"bordercolor": "#123456"}},
+ {"template": {"layout": {"hoverlabel": {"bordercolor": "#123456"}}}},
+ ],
+ )
+ def test_a_figure_hover_border_wins_over_the_series_border(
+ self, page: Page, server_port: int, layout: dict
+ ):
+ df = pl.DataFrame(
+ {"x": [float(i) for i in range(20)], "y": [1.0] * 20, "g": ["a", "b"] * 10}
+ )
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_hover_border",
+ df,
+ lambda d: (
+ d.add_figure()
+ .add_line(x="x", y="y", group_by="g")
+ .update_layout(**layout)
+ ),
+ )
+ page.goto(url)
+ _wait_for_init(page)
+
+ borders = page.evaluate(
+ "() => divs[0]._fullData.map(t => t.hoverlabel.bordercolor)"
+ )
+ assert borders == ["#123456", "#123456"]
+
+ @pytest.mark.parametrize(
+ ("scheme", "layout", "guide"),
+ [
+ ("dark", {"template": {"layout": {}}}, "rgba(27, 35, 44, 0.75)"),
+ ("dark", {"plot_bgcolor": "#ffffff"}, "rgba(27, 35, 44, 0.75)"),
+ ("light", {"plot_bgcolor": "#111111"}, "rgba(230, 237, 243, 0.7)"),
+ ],
+ )
+ def test_hover_guides_follow_the_plot_background(
+ self, page: Page, server_port: int, scheme: str, layout: dict, guide: str
+ ):
+ # A figure can set its plot background apart from the page mode.
+ df = pl.DataFrame({"x": [float(i) for i in range(20)], "y": [1.0] * 20})
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_guides",
+ df,
+ lambda d: d.add_figure().add_line(x="x", y="y").update_layout(**layout),
+ )
+ page.emulate_media(color_scheme=scheme)
+ page.goto(url)
+ _wait_for_init(page)
+
+ drawn = page.evaluate("""() => {
+ const uid = DASHBOARD_SPEC.figures[0].uid;
+ window.__fvApplyHoverVisuals(uid, [{type: 'x_guide', value: 10}]);
+ return getComputedStyle(document.querySelector('.fv-hover-guide'))
+ .borderLeftColor;
+ }""")
+ assert drawn == guide
+
+ def test_overlay_legend_swatches_keep_full_opacity(
+ self, page: Page, server_port: int
+ ):
+ page.goto(_dashboard_url_grouped(server_port, "line", n_figures=2))
+ _wait_for_init(page)
+ page.evaluate("""() => window.flexvizApply({state: {
+ cross_filter_mode: 'overlay',
+ selections: [{
+ source_figure_uid: DASHBOARD_SPEC.figures[0].uid,
+ predicates: [{clauses: [{column: 'ts', range: [10, 30]}]}],
+ }],
+ }})""")
+ page.wait_for_function("() => divs[1].data.some(t => t.opacity < 1)")
+
+ swatches = page.evaluate("""() => [...divs[1].querySelectorAll('.legend .traces .layers')]
+ .map(el => getComputedStyle(el).opacity)""")
+ assert swatches == ["1", "1"]
+
+ def test_map_tiles_follow_the_mode(self, page: Page, server_port: int):
+ # A figure with its own template gets no theme, but never Plotly's
+ # default map style: it loads CARTO tiles.
+ layouts = [
+ {},
+ {"map": {"style": "white-bg"}},
+ {"template": {"layout": {"font": {"family": "serif"}}}},
+ {"template": {"layout": {"map": {"style": "white-bg"}}}},
+ ]
+
+ def build(dash):
+ for layout in layouts:
+ dash.add_figure().add_geo_histogram2d(
+ lat="lat", lon="lon", lat_bins=4, lon_bins=4
+ ).update_layout(**layout)
+
+ page.emulate_media(color_scheme="light")
+ page.goto(
+ _color_norm_url(server_port, "_browser_theme_map", _geo_browser_df(), build)
+ )
+ _wait_for_init(page)
+ read_styles = """() => divs.map(gd => {
+ const style = gd._fullLayout.map.style;
+ return typeof style === 'string' ? style : style.id;
+ })"""
+ light = ["open-street-map", "white-bg", "open-street-map", "white-bg"]
+ assert page.evaluate(read_styles) == light
+
+ _switch_to_dark(page)
+ assert page.evaluate(read_styles) == ["fv-osm-dark", *light[1:]]
+ # The darkened OSM tiles stay under the cells and keep their attribution.
+ page.wait_for_function("""() => {
+ const layers = divs[0]._fullLayout.map._subplot.map.getStyle().layers;
+ return layers.length > 1 && 'raster-saturation' in (layers[0].paint || {});
+ }""")
+ assert "OpenStreetMap" in page.inner_text("#fv-plot-0 .maplibregl-ctrl-attrib")
+
+ @pytest.mark.parametrize(
+ "layout",
+ [
+ {"font": None},
+ {"font": {"family": None, "size": None, "color": None}},
+ {"font": "serif"},
+ {"template": None},
+ ],
+ )
+ def test_an_unset_or_invalid_layout_value_keeps_the_theme(
+ self, page: Page, server_port: int, layout: dict
+ ):
+ # Python None reaches the page as null, which Plotly reads as unset.
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_unset",
+ _geo_browser_df(),
+ lambda d: (
+ d.add_figure(title="Map")
+ .add_geo_histogram2d(lat="lat", lon="lon", lat_bins=4, lon_bins=4)
+ .update_layout(**layout)
+ ),
+ )
+ page.emulate_media(color_scheme="light")
+ page.goto(url)
+ _wait_for_init(page)
+
+ drawn = page.evaluate("""() => {
+ const l = divs[0]._fullLayout;
+ return [l.paper_bgcolor, l.map.style, l.title.font.size];
+ }""")
+ assert drawn == ["#ffffff", "open-street-map", 14]
+
+ def test_dark_mode_lifts_the_low_end_of_viridis(self, page: Page, server_port: int):
+ page.emulate_media(color_scheme="dark")
+ page.goto(_dashboard_url_hist2d(server_port))
+ _wait_for_init(page)
+ read = """() => ({
+ scale: divs[0]._fullData[0].colorscale,
+ bar: (() => {
+ const fill = divs[0].querySelector('.cbfill').style.fill;
+ const id = fill.match(/#([^"')]+)/)[1];
+ return [...document.getElementById(id).querySelectorAll('stop')]
+ .map(stop => stop.getAttribute('stop-color'));
+ })(),
+ })"""
+
+ dark = page.evaluate(read)
+ assert dark["scale"][0] == [0, "#424086"]
+ assert dark["scale"][-1] == [1, "#fde725"]
+ # The colorbar draws the same scale as the cells.
+ assert "rgb(66, 64, 134)" in dark["bar"]
+ assert "rgb(68, 1, 84)" not in dark["bar"]
+
+ page.click("#fv-btn-mode") # Auto (dark system) -> Light
+ page.wait_for_function(
+ "() => divs[0]._fullData[0].colorscale[0][1] === '#440154'"
+ )
+
+ def test_a_figure_with_its_own_template_keeps_plain_viridis(
+ self, page: Page, server_port: int
+ ):
+ df = pl.DataFrame(
+ {"x": [float(i) for i in range(50)], "y": [float(i % 7) for i in range(50)]}
+ )
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_own_viridis",
+ df,
+ lambda d: (
+ d.add_figure()
+ .add_histogram2d(x="x", y="y")
+ .update_layout(template={"layout": {}})
+ ),
+ )
+ page.emulate_media(color_scheme="dark")
+ page.goto(url)
+ _wait_for_init(page)
+
+ low = page.evaluate("() => divs[0]._fullData[0].colorscale[0]")
+ assert low == [0, "#440154"]
+
+ def test_long_category_tick_labels_are_not_cut_off(
+ self, page: Page, server_port: int
+ ):
+ df = pl.DataFrame(
+ {
+ "temperature_celsius": [float(i) for i in range(50)],
+ "vibration_rms": [float(i % 7) for i in range(50)],
+ }
+ )
+ url = _color_norm_url(
+ server_port,
+ "_browser_theme_ticks",
+ df,
+ lambda d: d.add_figure().add_corr_heatmap(
+ columns=["temperature_celsius", "vibration_rms"]
+ ),
+ )
+ page.goto(url)
+ _wait_for_init(page)
+
+ overflow = page.evaluate("""() => {
+ const left = divs[0].getBoundingClientRect().left;
+ const ticks = [...divs[0].querySelectorAll('.ytick text')];
+ return ticks.length && Math.min(...ticks.map(t => t.getBoundingClientRect().left)) - left;
+ }""")
+ assert overflow >= 0
diff --git a/tests/test_browser_cube.py b/tests/test_browser_cube.py
index 29f1822..5839124 100644
--- a/tests/test_browser_cube.py
+++ b/tests/test_browser_cube.py
@@ -620,7 +620,7 @@ def test_live_brush_zero_roundtrips(self, page: Page, server_port: int):
url = _two_hist_dashboard_url(server_port, "_cube_browser_auto_rt", "auto")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -678,7 +678,7 @@ def test_commit_counts_match_reference(self, page: Page, server_port: int):
df = _cube_df()
url = _two_hist_dashboard_url(server_port, "_cube_browser_auto_ref", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -746,7 +746,7 @@ def test_live_brush_off_is_legacy(self, page: Page, server_port: int):
url = _two_hist_dashboard_url(server_port, "_cube_browser_off", "off")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -803,7 +803,7 @@ def test_second_brush_is_store_hit(self, page: Page, server_port: int):
bodies = _capture_updates(page)
posts = _capture_posts(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# Gesture 1: populates the store via the one cube_request.
@@ -864,7 +864,7 @@ def test_reversed_zoomed_source_live_updates(self, page: Page, server_port: int)
f"http://127.0.0.1:{server_port}/view"
f"?spec={encode_spec(spec)}&renderer=plotly"
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
x1, x2, y, _ = _drag_coords(page)
@@ -889,7 +889,7 @@ def test_mixed_dashboard_commit_posts_once(self, page: Page, server_port: int):
url = _mixed_dashboard_url(server_port, "_cube_browser_mixed")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
box_props = ["lowerfence", "q1", "median", "q3", "upperfence"]
@@ -983,7 +983,7 @@ def test_abandoned_gesture_restores_targets(self, page: Page, server_port: int):
url = _two_hist_dashboard_url(server_port, "_cube_browser_abandon", "auto")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -1035,7 +1035,7 @@ def test_abandoned_gesture_keeps_a_pending_response(
apply: the abort puts back the pre-drag data, not newer data."""
url = _two_hist_dashboard_url(server_port, "_cube_browser_abandon_late", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.evaluate(_COUNT_SETTLED_UPDATES)
_enter_select_mode(page)
held = _hold_first_update(page)
@@ -1079,7 +1079,7 @@ def test_reset_deselect_unchanged(self, page: Page, server_port: int):
errors: list[str] = []
page.on("pageerror", lambda exc: errors.append(str(exc)))
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_unfiltered = _target_y(page)
@@ -1122,7 +1122,7 @@ def test_grouped_hist_target_live_updates(self, page: Page, server_port: int):
url = _grouped_dashboard_url(server_port, "_cube_browser_grouped")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
read_children = (
@@ -1179,7 +1179,7 @@ def test_bar_and_pie_targets_live_update(self, page: Page, server_port: int):
url = _bar_pie_dashboard_url(server_port, "_cube_browser_barpie")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_before = _bar_xy(page, "#fv-plot-1")
@@ -1249,7 +1249,7 @@ def test_mean_bar_target_commit_parity(self, page: Page, server_port: int):
url = _mean_bar_dashboard_url(server_port, "_cube_browser_meanbar")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_before = _bar_xy(page, "#fv-plot-1")
@@ -1302,7 +1302,7 @@ def test_median_bar_target_mixed_dashboard(self, page: Page, server_port: int):
url = _median_mixed_dashboard_url(server_port, "_cube_browser_median")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_unfiltered = _target_y(page)
@@ -1383,7 +1383,7 @@ def test_float_label_bar_target_live_updates(self, page: Page, server_port: int)
url = _numeric_label_dashboard_url(server_port, "_cube_browser_numlab")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_unfiltered = _target_y(page)
@@ -1450,7 +1450,7 @@ def test_grouped_bar_live_brush_preserves_group_colors(
every bar black until the commit round-trip self-heals it)."""
url = _grouped_color_bar_dashboard_url(server_port, "_cube_browser_gcolor")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
read_children = (
@@ -1500,7 +1500,7 @@ def test_integer_label_bar_live_updates(self, page: Page, server_port: int):
url = _int_label_dashboard_url(server_port, "_cube_browser_intlab")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_before = _bar_xy(page, "#fv-plot-1")
@@ -1565,7 +1565,7 @@ def test_grouped_integer_label_bar_live_brush_demo_parity(
url = _grouped_int_color_dashboard_url(server_port, "_cube_browser_gintcol")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
read_children = (
@@ -1651,7 +1651,7 @@ def test_edit_existing_box_live_brush(
bodies = _capture_updates(page)
posts = _capture_posts(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# Gesture 1: a fresh draw commits a snapped box and warms the store.
@@ -2082,7 +2082,7 @@ def test_bar_source_drag_live_updates_hist(self, page: Page, server_port: int):
url = _bar_source_dashboard_url(server_port, "_cube_browser_barsrc", "auto")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _hist_y(page, "#fv-plot-1")
@@ -2143,7 +2143,7 @@ def test_edit_existing_bar_box_live_brush(self, page: Page, server_port: int):
bodies = _capture_updates(page)
posts = _capture_posts(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# Gesture 1: a fresh draw over g0, g1 commits and warms the store.
@@ -2238,7 +2238,7 @@ def test_integer_bar_source_drag_live_updates_hist(
url = _int_bar_source_dashboard_url(server_port, "_cube_browser_intbarsrc")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# The integer source bars render in NUMERIC order 0..3 at init.
@@ -2301,7 +2301,7 @@ def test_integer_bar_source_drag_live_updates_bar_and_pie_targets(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
assert _bar_xy(page, "#fv-plot-0")["x"] == [0, 1, 2, 3]
@@ -2382,7 +2382,7 @@ def test_grouped_integer_bar_source_drag_live_updates_grouped_bar_target(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
read_children = (
@@ -2455,7 +2455,7 @@ def test_float_bar_source_drag_live_updates_bar_and_pie_targets(
url = _float_bar_source_dashboard_url(server_port, "_cube_browser_floatbarsrc")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
assert [float(v) for v in _bar_xy(page, "#fv-plot-0")["x"]] == [
@@ -2531,7 +2531,7 @@ def test_pie_click_second_click_local(self, page: Page, server_port: int):
url = _pie_source_dashboard_url(server_port, "_cube_browser_pieclick")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
n_before = len(bodies)
_click_pie_slice(page, "#fv-plot-0", "g0")
@@ -2575,7 +2575,7 @@ def test_float_pie_click_second_click_local(self, page: Page, server_port: int):
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
n_before = len(bodies)
_click_pie_slice(page, "#fv-plot-0", 0)
@@ -2614,7 +2614,7 @@ def test_treemap_depth2_click_prefix_served(self, page: Page, server_port: int):
url = _treemap_source_dashboard_url(server_port, "_cube_browser_treemapclick")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-0')"
"?.data?.[0]?.ids?.includes('root/g0/h1')"
@@ -2673,7 +2673,7 @@ def test_live_brush_off_categorical_legacy(self, page: Page, server_port: int):
url = _categorical_legacy_dashboard_url(server_port, "_cube_browser_catoff")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _hist_y(page, "#fv-plot-2")
@@ -2726,7 +2726,7 @@ def test_bar_source_drag_live_updates_line_and_corr(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
line_before = _line_xy(page, "#fv-plot-1")
@@ -2801,7 +2801,7 @@ def test_treemap_click_in_bar_treemap_figure_uses_clicked_trace(
url = _ambiguous_source_dashboard_url(server_port, "_cube_browser_ambig")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || [])"
".some(t => (t.ids || []).includes?.('root/g0/h1'))"
@@ -2887,7 +2887,7 @@ def handle(route):
page.route("**/dashboard/update", handle)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -2942,7 +2942,7 @@ def test_oversized_entry_refused_smaller_admitted(
still admitted afterwards and the byte counter stays exact."""
url = _two_hist_dashboard_url(server_port, "_cube_browser_bytes", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate("""() => {
const prevBudget = fvCubeStoreSetBudget(1000);
@@ -2994,7 +2994,7 @@ def test_index_over_budget_is_sized_not_built(self, page: Page, server_port: int
blob = encode_fvcube(build_cube(df.lazy(), spec), "k")
url = _two_hist_dashboard_url(server_port, "_cube_browser_index", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
result = page.evaluate(
"""(b64) => {
@@ -3027,7 +3027,7 @@ def test_oversized_cube_degrades_gesture_cleanly(
url = _two_hist_dashboard_url(server_port, "_cube_browser_bigblob", "auto")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
page.evaluate("fvCubeStoreSetBudget(64)") # below any real cube
@@ -3118,7 +3118,7 @@ def test_fv_json_dumps_ascii_matches_python_goldens(
url = _two_hist_dashboard_url(server_port, "_cube_browser_jsondumps", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
cases = [("é", "x"), ("🎉", "x"), ("\x07", "x"), ("\x7f", "x"), ("a", "x")]
for parts in cases:
@@ -3145,7 +3145,7 @@ def test_composite_nonascii_bar_cube_delta_matches_server(
expected_colors = [cmap[label] for label in expected_labels]
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_init = _bar_xy(page, "#fv-plot-1")
@@ -3209,7 +3209,7 @@ def test_null_label_cube_delta_matches_server(self, page: Page, server_port: int
which also emits the raw null (nulls sort first on both sides)."""
url = _null_label_bar_dashboard_url(server_port, "_cube_browser_nulllabel")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_init = _bar_xy(page, "#fv-plot-1")
@@ -3248,7 +3248,7 @@ def test_nan_label_cube_delta_matches_server(self, page: Page, server_port: int)
pl.Series("g", [values[i % 3] for i in range(df_height)], dtype=pl.Float64),
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
bar_init = _bar_xy(page, "#fv-plot-1")
@@ -3373,7 +3373,7 @@ def test_committed_cube_delta_matches_server(
screen. It must equal the server delta for the committed spec: same
traces in the same order, same labels in the same order."""
page.goto(_label_parity_url(server_port, kind))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
init = page.eval_on_selector("#fv-plot-1", _READ_PARITY)
@@ -3517,7 +3517,7 @@ def test_rebrush_with_foreign_selection_never_serves_stale_cubes(
url = _three_hist_dashboard_url(server_port, "_cube_browser_stale")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# --- Phase 1: brush + commit on A (zero-passive, live, skipPost) ---
_fig_select_mode(page, 0)
@@ -3625,7 +3625,7 @@ def test_fv_cube_passive_key_canonicalization(self, page: Page, server_port: int
included."""
url = _two_hist_dashboard_url(server_port, "_cube_browser_pkeyjs", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
checks = page.evaluate("""() => {
const sel = (uid, ...preds) =>
({source_figure_uid: uid, predicates: preds});
@@ -3679,7 +3679,7 @@ def test_lazy_second_selection_one_request_then_live(
url = _three_hist_dashboard_url(server_port, "_cube_browser_lazy2")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Brush+commit A (zero-passive warm, skipPost).
_fig_select_mode(page, 0)
@@ -3720,7 +3720,7 @@ def test_deselect_reverts_to_zero_passive_store_hit(
url = _three_hist_dashboard_url(server_port, "_cube_browser_desel")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# 1. Brush+commit B — warms B-gesture zero-passive cubes (A + C).
_fig_select_mode(page, 1)
@@ -3769,7 +3769,7 @@ def test_pie_click_with_foreign_selection_passive_keyed(
url = _pie_with_other_dashboard_url(server_port, "_cube_browser_piefgn")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Foreign selection: brush+commit on Other (fig 2, hist a).
_fig_select_mode(page, 2)
@@ -3858,7 +3858,7 @@ def test_first_brush_bg_ghost_and_live_fg(self, page: Page, server_port: int):
url = _overlay_dashboard_url(server_port, "_cube_browser_ovl")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
unfiltered = [
@@ -3918,7 +3918,7 @@ def test_abandoned_gesture_restores_layers_incl_bg(
rendering — fg gone, bg back to full opacity."""
url = _overlay_dashboard_url(server_port, "_cube_browser_ovlabandon")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
before = _rendered_layers(page, "#fv-plot-1")
@@ -3953,7 +3953,7 @@ def test_mixed_overlay_dashboard_commit_posts_once(
url = _overlay_dashboard_url(server_port, "_cube_browser_ovlmix", with_box=True)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
unfiltered = [
@@ -4022,7 +4022,7 @@ def test_live_brush_off_overlay_is_legacy(self, page: Page, server_port: int):
url = _overlay_dashboard_url(server_port, "_cube_browser_ovloff", "off")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
before = _rendered_layers(page, "#fv-plot-1")
@@ -4151,7 +4151,7 @@ def test_datetime_source_live_brush_and_commit_parity(
url = _temporal_dashboard_url(server_port, "_cube_browser_tsus")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4172,7 +4172,7 @@ def test_zoomed_temporal_source_gesture(self, page: Page, server_port: int):
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4194,7 +4194,7 @@ def test_date_source_commits_bar_edges(self, page: Page, server_port: int):
url = _temporal_dashboard_url(server_port, "_cube_browser_tsday", kind="date")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4279,7 +4279,7 @@ def test_zoomed_source_brush_snaps_to_viewport_grid(
url = _zoomed_dashboard_url(server_port, "_cube_browser_zsrc", src_zoom=zoom)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4309,7 +4309,7 @@ def test_zoomed_target_new_key_then_reset_serves_old_entry(
url = _zoomed_dashboard_url(server_port, "_cube_browser_ztgt")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
# Brush 1 (unzoomed): warms the unzoomed-target key.
@@ -4369,7 +4369,7 @@ def test_zoomed_source_with_foreign_selection(self, page: Page, server_port: int
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
# Foreign selection: brush+commit on Third (hist c).
_fig_select_mode(page, 2)
@@ -4459,7 +4459,7 @@ def test_box_source_brush_live_updates_hist(self, page: Page, server_port: int):
url = _box_source_dashboard_url(server_port, "_cube_browser_boxsrc")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4489,7 +4489,7 @@ def test_line_source_brush_x_only_snapped_commit(
url = _line_source_dashboard_url(server_port, "_cube_browser_linesrc")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_fig_select_mode(page, 0)
n0 = len(bodies)
@@ -4798,7 +4798,7 @@ def test_apply_line_gaps_helper_temporal(self, page: Page, server_port: int):
exceeds 4.1 * median(1 day), so one null lands before the final point."""
url = _temporal_line_gap_url(server_port, "_line_gap_helper")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
out = page.evaluate("""() => window.fvApplyLineGaps(
['2020-01-01', '2020-01-02', '2020-01-03', '2020-09-01'],
[1, 2, 3, 4], true)""")
@@ -4819,7 +4819,7 @@ def test_apply_line_gaps_helper_tz_aware(
so the committed line interpolated across the band)."""
url = _temporal_line_gap_url(server_port, "_line_gap_tz")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
out = page.evaluate(
"""(s) => window.fvApplyLineGaps(
['2020-01-01T00:00:00'+s, '2020-01-02T00:00:00'+s,
@@ -4843,7 +4843,7 @@ def test_temporal_to_physical_converts_offset_to_utc_instant(
-05:30 -- must collapse to a SINGLE physical value (the µs epoch)."""
url = _temporal_line_gap_url(server_port, "_tz_phys_arith")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
vals = page.evaluate("""() => ({
z: window.fvTemporalToPhysical('2020-01-01T00:00:00Z', 'us'),
naive: window.fvTemporalToPhysical('2020-01-01T00:00:00', 'us'),
@@ -4873,7 +4873,7 @@ def test_temporal_init_line_gap_is_client_rendered(
client (fvApplyLineGaps) inserts the gap from ISO-string x."""
url = _temporal_line_gap_url(server_port, "_line_temporal_gap")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
xy = _line_xy(page, "#fv-plot-0")
assert len(xy["y"]) > 0
assert None in xy["y"], "temporal init line must break across the band"
@@ -4887,7 +4887,7 @@ def test_tz_aware_init_line_gap_is_client_rendered(
empty date band -- regression: the tz suffix defeated the gap parser."""
url = _temporal_line_gap_url(server_port, "_line_tz_gap", tz="UTC")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
xy = _line_xy(page, "#fv-plot-0")
assert len(xy["y"]) > 0
assert None in xy["y"], "tz-aware temporal init line must break across band"
@@ -4926,7 +4926,7 @@ def test_temporal_histogram_renders_on_date_axis(
every point counted (regression: fixed_hist panicked → no trace)."""
url = _temporal_histogram_url(server_port, f"_temp_hist_{tz}", tz=tz)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
info = page.eval_on_selector(
"#fv-plot-0",
"""gd => ({
@@ -4955,7 +4955,7 @@ def test_temporal_line_target_live_updates_on_date_axis(
url = _temporal_line_target_dashboard_url(server_port, "_cube_tline_tgt")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# The target axis is a date axis and the server seeds it with ISO
@@ -5012,7 +5012,7 @@ def test_grouped_temporal_line_target_live_updates_on_date_axis(
server_port, "_cube_gtline_tgt"
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
assert page.evaluate("divs[1]._fullLayout.xaxis.type") == "date"
@@ -5065,7 +5065,7 @@ def test_hist_source_live_updates_line_target(self, page: Page, server_port: int
url = _line_target_dashboard_url(server_port, "_cube_browser_line_live")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
line_before = _line_xy(page, "#fv-plot-1")
@@ -5121,7 +5121,7 @@ def test_live_brush_draws_line_gaps(self, page: Page, server_port: int, mode: st
``base`` in update) and asserts the gap is present mid-drag."""
url = _line_gap_dashboard_url(server_port, f"_cube_line_gap_{mode}", mode)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# Sanity: the rendered init line breaks across the empty band. The
@@ -5182,7 +5182,7 @@ def test_line_target_commit_always_posts_and_matches_no_cube(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
x1, x2, y, _width = _drag_coords(page)
@@ -5260,7 +5260,7 @@ def test_line_free_dashboard_still_skips_post(self, page: Page, server_port: int
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_before = _target_y(page)
@@ -5286,7 +5286,7 @@ def test_grouped_line_target_live_updates_children(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
read_children = (
@@ -5473,7 +5473,7 @@ def test_hist_source_live_updates_corr_target(self, page: Page, server_port: int
url = _corr_target_dashboard_url(server_port, "_cube_browser_corr_live")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_before = _corr_z(page)
@@ -5522,7 +5522,7 @@ def test_corr_target_conditional_commit_skips_post(
url = _corr_target_dashboard_url(server_port, "_cube_browser_corr_commit")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_before = _corr_z(page)
@@ -5584,7 +5584,7 @@ def test_spearman_corr_mixed_commit_self_heals(self, page: Page, server_port: in
url = _corr_mixed_dashboard_url(server_port, "_cube_browser_corr_spearman")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_corr_before = _corr_z(page, "#fv-plot-2")
@@ -5771,7 +5771,7 @@ def test_box2d_live_updates_hist_and_bar(self, page: Page, server_port: int):
url = _box2d_dashboard_url(server_port, "_cube_box2d_live")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
hist_before = _target_y(page) # hist(c) is #fv-plot-1
@@ -5825,7 +5825,7 @@ def test_box2d_commit_skips_post_with_two_clauses(
url = _box2d_dashboard_url(server_port, "_cube_box2d_commit")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
hist_before = _target_y(page)
@@ -5885,7 +5885,7 @@ def test_box2d_edit_drag_replays_live(self, page: Page, server_port: int):
bodies = _capture_updates(page)
posts = _capture_posts(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
# Gesture 1: a fresh box-select commits + warms the store.
@@ -5975,7 +5975,7 @@ def test_box2d_live_brush_off_is_legacy(self, page: Page, server_port: int):
url = _box2d_dashboard_url(server_port, "_cube_box2d_off", "off")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
coords = _box2d_drag_coords(page)
@@ -6108,7 +6108,7 @@ def test_overlay_initial_load_renders_unfiltered_hist2d_target(
cross_filter_mode="overlay",
)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
z = _hist2d_z(page)
assert len(z) == _H2T_NB_Y and len(z[0]) == _H2T_NB_X
@@ -6124,7 +6124,7 @@ def test_overlay_reset_restores_unfiltered_hist2d_target(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
z_unfiltered = _hist2d_z(page)
page.click("#fv-btn-cfmode")
@@ -6169,7 +6169,7 @@ def test_hist_source_live_updates_hist2d_target(self, page: Page, server_port: i
url = _hist2d_target_dashboard_url(server_port, "_cube_browser_h2d_live")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_before = _hist2d_z(page)
@@ -6216,7 +6216,7 @@ def test_hist_source_live_updates_mean_hist2d_target(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_before = _hist2d_z(page)
@@ -6258,7 +6258,7 @@ def test_hist2d_target_conditional_commit_parity(
url = _hist2d_target_dashboard_url(server_port, "_cube_browser_h2d_commit")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
z_before = _hist2d_z(page)
@@ -6402,7 +6402,7 @@ def test_fv_url_quote_matches_python_quote(self, page: Page, server_port: int):
url = _treemap_target_dashboard_url(server_port, "_cube_browser_tm_urlquote")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
cases = [" ", "/", "!", "*", "'", "(", ")", "~", "%", "é", "&", "=", "a b"]
for ch in cases:
@@ -6419,7 +6419,7 @@ def test_byte_identical_rollup_vs_server(self, page: Page, server_port: int):
df = _treemap_target_df()
url = _treemap_target_dashboard_url(server_port, "_cube_browser_tm_rollup")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
before = _treemap_data(page)
@@ -6466,7 +6466,7 @@ def test_source_brush_live_updates_treemap_and_commit_parity(
url = _treemap_target_dashboard_url(server_port, "_cube_browser_tm_live")
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
root_before = _treemap_data(page)["values"][0]
@@ -6539,7 +6539,7 @@ def test_mean_agg_treemap_live_and_commit_parity(
)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
before = _treemap_data(page)["values"]
@@ -6609,7 +6609,7 @@ def test_brush_over_bars_commits_their_rows(self, page: Page, server_port: int):
df = _edge_df()
url = _two_hist_dashboard_url(server_port, "_cube_bar_grid", "auto", df=df)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
source_counts = _hist_y(page, "#fv-plot-0")
@@ -6641,7 +6641,7 @@ def test_brush_covering_no_bar_center_clears(self, page: Page, server_port: int)
df = _edge_df()
url = _two_hist_dashboard_url(server_port, "_cube_bar_clear", "auto", df=df)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
y_unfiltered = _target_y(page)
@@ -6703,7 +6703,7 @@ def test_empty_snap_mid_drag_shows_pre_gesture_state(
else:
url = _two_hist_dashboard_url(server_port, "_cube_empty_frame", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
before = _rendered_layers(page, "#fv-plot-1")
lo, hi, bars = _free_grid(df)
@@ -6730,7 +6730,7 @@ def test_header_guard_rejects_another_traces_grid(
match. A blob built for another trace's grid is rejected."""
url = _two_hist_dashboard_url(server_port, "_cube_hdr_guard", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
got = page.evaluate("""() => {
const key = (free) => JSON.stringify({s: 's', free});
const one = (p, d, hp, dom, kind = 'continuous') => fvCubeHeaderMatchesKey(
@@ -6792,7 +6792,7 @@ def test_histogram_is_the_source_over_a_line_on_its_column(
f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}"
"&renderer=plotly"
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
hist_uid = next(
t.uid for t in spec.figures[0].traces if t.trace_type == "histogram"
)
@@ -6807,7 +6807,7 @@ def test_js_snap_matches_python_snap_brush(self, page: Page, server_port: int):
"""fvCubeSnap and flexviz.cube.snap_brush agree bit for bit."""
url = _two_hist_dashboard_url(server_port, "_cube_snap_parity", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
cases = []
for lo, hi in (
(0.0, 100.0),
@@ -6861,7 +6861,7 @@ def test_temporal_commit_edges_are_the_kernel_bin_starts(
url = _two_hist_dashboard_url(server_port, "_cube_unit_edges", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
rng = random.Random(7)
cases = []
for _ in range(300):
@@ -6898,7 +6898,7 @@ def test_commit_edges_past_exact_float_integers_return(
url = _two_hist_dashboard_url(server_port, "_cube_far_edges", "auto")
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
lo, hi = 1.5778368e15, 2.534022144e17 # 2020-01-01, 9999-12-31 in µs
edges = page.evaluate(
"""([lo, hi]) => {
@@ -6920,7 +6920,7 @@ def test_constant_column_brush_selects_every_row(
df = pl.DataFrame({"a": [1e7] * 10, "b": [float(v) for v in range(10)]})
url = _two_hist_dashboard_url(server_port, "_cube_constant", "auto", df=df)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
_brush_commit(page, 0, 0.48, 0.52)
page.wait_for_timeout(300)
@@ -6963,7 +6963,7 @@ def test_zoomed_box2d_adopts_the_snapped_cell_grid(
url = _box2d_zoomed_url(server_port, "_cube_box2d_zoomed", zoom)
bodies = _capture_updates(page)
page.goto(url)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
hist_before = _target_y(page)
coords = _box2d_drag_coords(page)
@@ -7033,7 +7033,7 @@ def _short_date_page(page: Page, server_port: int):
page.goto(
f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}&renderer=plotly"
)
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
_enter_select_mode(page)
return df, spec
diff --git a/tests/test_browser_predicate_dashboard.py b/tests/test_browser_predicate_dashboard.py
index aa2fed6..4caf073 100644
--- a/tests/test_browser_predicate_dashboard.py
+++ b/tests/test_browser_predicate_dashboard.py
@@ -131,7 +131,7 @@ class TestPredicateDashboard:
def test_init_renders_all_six_figures(self, page: Page, server_port: int):
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
for i in range(6):
page.wait_for_function(
f"() => document.querySelector('#fv-plot-{i}')?.data?.length > 0"
@@ -141,7 +141,7 @@ def test_treemap_leaf_click_filters_all_targets(self, page: Page, server_port: i
"""Click leaf 'solar/NL' on treemap (fig 4) → verify line, both bars, pie, hist
all show only solar+NL data."""
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-4')?.data?.[0]?.ids?.includes('root/solar/NL')"
)
@@ -173,7 +173,7 @@ def test_treemap_leaf_click_filters_all_targets(self, page: Page, server_port: i
def test_pie_multi_label_click_filters_targets(self, page: Page, server_port: int):
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-3')?.data?.[0]?.labels?.length > 1"
)
@@ -208,7 +208,7 @@ def test_pie_multi_label_click_filters_targets(self, page: Page, server_port: in
def test_bar_single_label_brush_filters_treemap(self, page: Page, server_port: int):
# Programmatically post a categorical predicate on country=NL.
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-2')?.data || []).length > 0"
)
@@ -238,7 +238,7 @@ def test_bar_single_label_brush_filters_treemap(self, page: Page, server_port: i
def test_line_range_brush_filters_targets(self, page: Page, server_port: int):
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || []).length > 0"
)
@@ -264,7 +264,7 @@ def test_line_box_select_emits_x_only_clause(self, page: Page, server_port: int)
"""A 2-D box-select on the line (x=ts, y=val) must yield only the x (ts)
clause — line selection is x-only, so the y range is never emitted."""
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || []).length > 0"
)
@@ -285,7 +285,7 @@ def test_line_selection_box_is_full_height_band(self, page: Page, server_port: i
renders as a full-height band), and a y-only edit must be a no-op that
snaps the band back rather than cropping it."""
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || []).length > 0"
)
@@ -333,7 +333,7 @@ def test_line_selection_box_is_full_height_band(self, page: Page, server_port: i
def test_treemap_double_click_toggles_off(self, page: Page, server_port: int):
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => document.querySelector('#fv-plot-4')?.data?.[0]?.ids?.includes('root/solar')"
)
@@ -354,7 +354,7 @@ def test_treemap_double_click_toggles_off(self, page: Page, server_port: int):
def test_repeated_select_deselect_cycle(self, page: Page, server_port: int):
page.goto(_build_predicate_dashboard_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-2')?.data || []).length > 0"
)
@@ -424,7 +424,7 @@ class TestMixedRangeGeometry:
def test_box_select_keeps_tightest_xy_predicate(self, page: Page, server_port: int):
page.goto(_build_line_over_hist2d_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || []).length > 1"
)
@@ -471,7 +471,7 @@ class TestSameColumnDifferentAxes:
def test_box_select_keeps_both_axis_ranges(self, page: Page, server_port: int):
page.goto(_build_same_col_two_axes_url(server_port))
- _wait_for_init(page, "plotly")
+ _wait_for_init(page)
page.wait_for_function(
"() => (document.querySelector('#fv-plot-0')?.data || []).length > 1"
)
diff --git a/tests/test_html_adapters.py b/tests/test_html_adapters.py
index e8c6aca..a6e1bae 100644
--- a/tests/test_html_adapters.py
+++ b/tests/test_html_adapters.py
@@ -10,6 +10,7 @@
from __future__ import annotations
+import re
from html.parser import HTMLParser
import pytest
@@ -1057,6 +1058,34 @@ def test_plotly_toolbar_css_uses_var_references(self, plotly_html):
assert "var(--fv-border)" in plotly_html
assert "var(--fv-text)" in plotly_html
+ def test_one_dark_block_serves_the_system_and_the_mode_button(self):
+ from flexviz.adapters.runtime import theme_css
+
+ css = theme_css()
+ assert len(re.findall(r'^:root\[data-fv-mode="dark"\]', css, re.MULTILINE)) == 1
+ assert "@media (prefers-color-scheme" not in css
+
+ def test_series_palette_is_the_same_in_both_modes(self):
+ # state.group_domains stores each group's hex, so the palette must not
+ # change with the mode.
+ from flexviz.adapters.runtime import theme_css
+
+ light, dark = theme_css().split('\n:root[data-fv-mode="dark"]')
+ okabe_ito = (
+ "#0072b2, #e69f00, #009e73, #cc79a7, #56b4e9, #d55e00, #9a8700, #8a8a8a"
+ )
+ assert f"--fv-series: {okabe_ito};" in light
+ assert "--fv-series" not in dark
+
+ def test_page_head_sets_the_mode_before_the_styles(self, plotly_html):
+ head = plotly_html.split("")[0]
+ assert "root.dataset.fvMode = mode" in head
+ assert head.index("root.dataset.fvMode") < head.index("