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