From 129ee39066bfdf46ef5d97039dac0110feb1bc57 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Fri, 7 Nov 2025 11:13:07 +0000 Subject: [PATCH 1/5] refactor: migrate from XState to Zustand for state management - Replace XState machine with Zustand store for simpler state management - Migrate feature highlighting functionality to Zustand - Migrate bounding box selection workflow to Zustand - Remove XState dependencies and xstate directory - All existing tests pass, including E2E bbox interaction test --- package-lock.json | 55 +++++++----- package.json | 3 +- src/index.tsx | 132 ++++++++++++++++++++--------- src/store.ts | 48 +++++++++++ src/toolbar.tsx | 34 ++++---- src/xstate/index.tsx | 18 ---- src/xstate/machine.ts | 180 ---------------------------------------- src/xstate/selectors.ts | 67 --------------- 8 files changed, 192 insertions(+), 345 deletions(-) create mode 100644 src/store.ts delete mode 100644 src/xstate/index.tsx delete mode 100644 src/xstate/machine.ts delete mode 100644 src/xstate/selectors.ts diff --git a/package-lock.json b/package-lock.json index a4f364ff..e0496962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@geoarrow/deck.gl-layers": "^0.4.0-beta.6", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", - "@xstate/react": "^6.0.0", "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", @@ -30,7 +29,7 @@ "react-map-gl": "^8.1.0", "threads": "1.7.0", "uuid": "^13.0.0", - "xstate": "^5.22.0" + "zustand": "^5.0.2" }, "devDependencies": { "@anywidget/types": "^0.2.0", @@ -7949,25 +7948,6 @@ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", "license": "MIT" }, - "node_modules/@xstate/react": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.0.0.tgz", - "integrity": "sha512-xXlLpFJxqLhhmecAXclBECgk+B4zYSrDTl8hTfPZBogkn82OHKbm9zJxox3Z/YXoOhAQhKFTRLMYGdlbhc6T9A==", - "license": "MIT", - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.2", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "xstate": "^5.20.0" - }, - "peerDependenciesMeta": { - "xstate": { - "optional": true - } - } - }, "node_modules/a5-js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", @@ -15656,6 +15636,8 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -15980,7 +15962,9 @@ "version": "5.23.0", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.23.0.tgz", "integrity": "sha512-jo126xWXkU6ySQ91n51+H2xcgnMuZcCQpQoD3FQ79d32a6RQvryRh8rrDHnH4WDdN/yg5xNjlIRol9ispMvzeg==", + "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" @@ -16045,6 +16029,35 @@ "license": "MIT", "optional": true, "peer": true + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 380a2e05..0778c1e0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@geoarrow/deck.gl-layers": "^0.4.0-beta.6", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", - "@xstate/react": "^6.0.0", "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", @@ -25,7 +24,7 @@ "react-map-gl": "^8.1.0", "threads": "1.7.0", "uuid": "^13.0.0", - "xstate": "^5.22.0" + "zustand": "^5.0.2" }, "type": "module", "devDependencies": { diff --git a/src/index.tsx b/src/index.tsx index eeae6d29..944a1785 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,16 @@ import { createRender, useModel, useModelState } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; import { MapViewState, PickingInfo } from "@deck.gl/core"; +import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; import { DeckGLRef } from "@deck.gl/react"; import type { IWidgetManager } from "@jupyter-widgets/base"; import { NextUIProvider } from "@nextui-org/react"; import debounce from "lodash.debounce"; import throttle from "lodash.throttle"; import * as React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; +import { PolygonLayer, PolygonLayerProps } from "@deck.gl/layers"; import { flyTo } from "./actions/fly-to.js"; import { @@ -32,8 +34,7 @@ import Toolbar from "./toolbar.js"; import { getTooltip } from "./tooltip/index.js"; import { Message } from "./types.js"; import { isDefined, isGlobeView, sanitizeViewState } from "./util.js"; -import { MachineContext, MachineProvider } from "./xstate"; -import * as selectors from "./xstate/selectors"; +import { useStore } from "./store"; import "maplibre-gl/dist/maplibre-gl.css"; import "./globals.css"; @@ -41,24 +42,63 @@ import "./globals.css"; await initParquetWasm(); function App() { - const actorRef = MachineContext.useActorRef(); - const isDrawingBBoxSelection = MachineContext.useSelector( - selectors.isDrawingBBoxSelection, - ); - const isOnMapHoverEventEnabled = MachineContext.useSelector( - selectors.isOnMapHoverEventEnabled, - ); - - const highlightedFeature = MachineContext.useSelector( - (s) => s.context.highlightedFeature, - ); - - const bboxSelectPolygonLayer = MachineContext.useSelector( - selectors.getBboxSelectPolygonLayer, - ); - const bboxSelectBounds = MachineContext.useSelector( - selectors.getBboxSelectBounds, - ); + const highlightedFeature = useStore((state) => state.highlightedFeature); + const setHighlightedFeature = useStore((state) => state.setHighlightedFeature); + + const isDrawingBbox = useStore((state) => state.isDrawingBbox); + const bboxSelectStart = useStore((state) => state.bboxSelectStart); + const bboxSelectEnd = useStore((state) => state.bboxSelectEnd); + const setBboxStart = useStore((state) => state.setBboxStart); + const setBboxEnd = useStore((state) => state.setBboxEnd); + const setBboxHover = useStore((state) => state.setBboxHover); + + // isOnMapHoverEventEnabled: hover is enabled when we're drawing and have a start point + const isOnMapHoverEventEnabled = isDrawingBbox && bboxSelectStart !== undefined; + + // Calculate bboxSelectBounds + const bboxSelectBounds = useMemo(() => { + if (bboxSelectStart && bboxSelectEnd) { + const [x0, y0] = bboxSelectStart; + const [x1, y1] = bboxSelectEnd; + return [ + Math.min(x0, x1), + Math.min(y0, y1), + Math.max(x0, x1), + Math.max(y0, y1), + ]; + } + return null; + }, [bboxSelectStart, bboxSelectEnd]); + + // Create bboxSelectPolygonLayer + const bboxSelectPolygonLayer = useMemo(() => { + if (bboxSelectStart && bboxSelectEnd) { + const bboxProps: PolygonLayerProps = { + id: "bbox-select-polygon", + data: [ + [ + [bboxSelectStart[0], bboxSelectStart[1]], + [bboxSelectEnd[0], bboxSelectStart[1]], + [bboxSelectEnd[0], bboxSelectEnd[1]], + [bboxSelectStart[0], bboxSelectEnd[1]], + ], + ], + getPolygon: (d) => d, + getFillColor: [0, 0, 0, 50], + getLineColor: [0, 0, 0, 130], + stroked: true, + getLineWidth: 2, + lineWidthUnits: "pixels", + }; + if (isDrawingBbox) { + bboxProps.getFillColor = [255, 255, 0, 120]; + bboxProps.getLineColor = [211, 211, 38, 200]; + bboxProps.getLineWidth = 2; + } + return new PolygonLayer(bboxProps); + } + return null; + }, [bboxSelectStart, bboxSelectEnd, isDrawingBbox]); const [justClicked, setJustClicked] = useState(false); @@ -138,7 +178,7 @@ function App() { model.widget_manager as IWidgetManager, updateStateCallback, bboxSelectBounds, - isDrawingBBoxSelection, + isDrawingBbox, setSelectedBounds, ); @@ -157,27 +197,41 @@ function App() { } } setJustClicked(true); - actorRef.send({ - type: "Map click event", - data: info, - }); + + // Handle feature highlighting with Zustand + const clickedObject = info.object; + if (typeof clickedObject !== "undefined") { + setHighlightedFeature(info as GeoArrowPickingInfo); + } else { + setHighlightedFeature(undefined); + } + + // Handle bbox selection with Zustand + if (isDrawingBbox && info.coordinate) { + if (bboxSelectStart === undefined) { + // First click: set start point + setBboxStart(info.coordinate); + } else { + // Second click: set end point and finish + setBboxEnd(info.coordinate); + } + } + setTimeout(() => { setJustClicked(false); }, 100); - }, []); + }, [setHighlightedFeature, isDrawingBbox, bboxSelectStart, setBboxStart, setBboxEnd]); const onMapHoverHandler = useCallback( throttle( - (info: PickingInfo) => - isOnMapHoverEventEnabled && - !justClicked && - actorRef.send({ - type: "Map hover event", - data: info, - }), + (info: PickingInfo) => { + if (isOnMapHoverEventEnabled && !justClicked && info.coordinate) { + setBboxHover(info.coordinate); + } + }, 100, ), - [isOnMapHoverEventEnabled, justClicked], + [isOnMapHoverEventEnabled, justClicked, setBboxHover], ); const mapRenderProps: MapRendererProps = { @@ -189,7 +243,7 @@ function App() { ? layers.concat(bboxSelectPolygonLayer) : layers, getTooltip: (showTooltip && getTooltip) || undefined, - getCursor: () => (isDrawingBBoxSelection ? "crosshair" : "grab"), + getCursor: () => (isDrawingBbox ? "crosshair" : "grab"), pickingRadius: pickingRadius, onClick: onMapClickHandler, onHover: onMapHoverHandler, @@ -243,7 +297,7 @@ function App() { {showSidePanel && highlightedFeature && ( actorRef.send({ type: "Close side panel" })} + onClose={() => setHighlightedFeature(undefined)} /> )}
@@ -261,9 +315,7 @@ function App() { const WrappedApp = () => ( - - - + ); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 00000000..7563f950 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; + +interface AppState { + highlightedFeature: GeoArrowPickingInfo | undefined; + setHighlightedFeature: (feature: GeoArrowPickingInfo | undefined) => void; + bboxSelectStart: number[] | undefined; + bboxSelectEnd: number[] | undefined; + isDrawingBbox: boolean; + startBboxSelection: () => void; + cancelBboxSelection: () => void; + clearBboxSelection: () => void; + setBboxStart: (coordinate: number[]) => void; + setBboxEnd: (coordinate: number[]) => void; + setBboxHover: (coordinate: number[]) => void; +} + +export const useStore = create((set, get) => ({ + highlightedFeature: undefined, + setHighlightedFeature: (feature) => set({ highlightedFeature: feature }), + bboxSelectStart: undefined, + bboxSelectEnd: undefined, + isDrawingBbox: false, + startBboxSelection: () => + set({ isDrawingBbox: true, bboxSelectStart: undefined, bboxSelectEnd: undefined }), + cancelBboxSelection: () => + set({ isDrawingBbox: false, bboxSelectStart: undefined, bboxSelectEnd: undefined }), + clearBboxSelection: () => + set({ bboxSelectStart: undefined, bboxSelectEnd: undefined }), + setBboxStart: (coordinate) => set({ bboxSelectStart: coordinate }), + setBboxEnd: (coordinate) => + set((state) => { + // Only finish drawing if a start point has been set + if (state.bboxSelectStart) { + return { bboxSelectEnd: coordinate, isDrawingBbox: false }; + } + return {}; + }), + setBboxHover: (coordinate) => + set((state) => { + // Only update hover if we're drawing and have a start point + if (state.isDrawingBbox && state.bboxSelectStart) { + return { bboxSelectEnd: coordinate }; + } + return {}; + }), +})); + diff --git a/src/toolbar.tsx b/src/toolbar.tsx index cd57bfe1..8c2e4c24 100644 --- a/src/toolbar.tsx +++ b/src/toolbar.tsx @@ -2,25 +2,25 @@ import { Button, ButtonGroup, Tooltip } from "@nextui-org/react"; import React from "react"; import { SquareIcon, XMarkIcon } from "./icons"; -import { MachineContext } from "./xstate"; -import * as selectors from "./xstate/selectors"; +import { useStore } from "./store"; const Toolbar: React.FC = () => { - const actorRef = MachineContext.useActorRef(); - const isDrawingBBoxSelection = MachineContext.useSelector( - selectors.isDrawingBBoxSelection, - ); - const isBboxDefined = MachineContext.useSelector( - (state) => state.context.bboxSelectStart && state.context.bboxSelectEnd, - ); + const isDrawingBbox = useStore((state) => state.isDrawingBbox); + const bboxSelectStart = useStore((state) => state.bboxSelectStart); + const bboxSelectEnd = useStore((state) => state.bboxSelectEnd); + const startBboxSelection = useStore((state) => state.startBboxSelection); + const cancelBboxSelection = useStore((state) => state.cancelBboxSelection); + const clearBboxSelection = useStore((state) => state.clearBboxSelection); + + const isBboxDefined = bboxSelectStart && bboxSelectEnd; const handleButtonClick = () => { - if (isDrawingBBoxSelection) { - actorRef.send({ type: "Cancel BBox draw" }); + if (isDrawingBbox) { + cancelBboxSelection(); } else if (isBboxDefined) { - actorRef.send({ type: "Clear BBox" }); + clearBboxSelection(); } else { - actorRef.send({ type: "BBox select button clicked" }); + startBboxSelection(); } }; @@ -44,7 +44,7 @@ const Toolbar: React.FC = () => { { diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 1d0298a4..13b9ef32 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -47,4 +47,3 @@ def sample_map(sample_layer): def sample_map_with_side_panel(sample_layer): """Lonboard map with side panel enabled for testing.""" return Map(sample_layer, show_side_panel=True) - diff --git a/tests/ui/test_bbox_interaction.py b/tests/ui/test_bbox_interaction.py index 642e5b3d..ce4bc1b1 100644 --- a/tests/ui/test_bbox_interaction.py +++ b/tests/ui/test_bbox_interaction.py @@ -2,7 +2,6 @@ import pytest from IPython.display import display - from test_utils import ( TestConstants, draw_bbox_on_canvas, diff --git a/tests/ui/test_bbox_selection.py b/tests/ui/test_bbox_selection.py index 7674f5d8..5ffb5c86 100644 --- a/tests/ui/test_bbox_selection.py +++ b/tests/ui/test_bbox_selection.py @@ -17,10 +17,14 @@ class TestBboxSelectionWorkflow: """Test bbox selection workflow.""" def test_complete_bbox_workflow(self, page_session, sample_map): - canvas = setup_map_widget(page_session, sample_map) + setup_map_widget(page_session, sample_map) # Initial state - select_button = wait_for_button(page_session, "select", TestConstants.TIMEOUT_BUTTON_CLICK) + select_button = wait_for_button( + page_session, + "select", + TestConstants.TIMEOUT_BUTTON_CLICK, + ) assert select_button.count() > 0 # Start selection @@ -61,7 +65,7 @@ def test_bbox_cancel_after_start_point(self, page_session, sample_map): assert verify_bbox_cleared(sample_map) def test_bbox_clear_after_completion(self, page_session, sample_map): - canvas = setup_map_widget(page_session, sample_map) + setup_map_widget(page_session, sample_map) start_bbox_selection(page_session) draw_bbox_on_canvas(page_session) @@ -111,10 +115,14 @@ class TestToolbarStates: """Test toolbar button states.""" def test_button_state_transitions(self, page_session, sample_map): - canvas = setup_map_widget(page_session, sample_map) + setup_map_widget(page_session, sample_map) # Initial state - select_button = wait_for_button(page_session, "select", TestConstants.TIMEOUT_BUTTON_CLICK) + select_button = wait_for_button( + page_session, + "select", + TestConstants.TIMEOUT_BUTTON_CLICK, + ) assert select_button.count() > 0 # Selection mode @@ -132,4 +140,3 @@ def test_button_state_transitions(self, page_session, sample_map): page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) select_button = wait_for_button(page_session, "select") assert select_button.count() > 0 - diff --git a/tests/ui/test_feature_highlighting.py b/tests/ui/test_feature_highlighting.py index 8a2e181b..8e88c5ec 100644 --- a/tests/ui/test_feature_highlighting.py +++ b/tests/ui/test_feature_highlighting.py @@ -37,7 +37,11 @@ def test_multiple_clicks_stability(self, page_session, sample_map_with_side_pane assert canvas.count() > 0 page_session.wait_for_timeout(200) - def test_feature_highlighting_with_bbox_mode(self, page_session, sample_map_with_side_panel): + def test_feature_highlighting_with_bbox_mode( + self, + page_session, + sample_map_with_side_panel, + ): canvas = setup_map_widget(page_session, sample_map_with_side_panel) # Start bbox mode @@ -56,4 +60,3 @@ def test_feature_highlighting_with_bbox_mode(self, page_session, sample_map_with # Verify return to normal select_button = wait_for_button(page_session, "select") assert select_button.count() > 0 - diff --git a/tests/ui/test_utils.py b/tests/ui/test_utils.py index 10390463..a28409eb 100644 --- a/tests/ui/test_utils.py +++ b/tests/ui/test_utils.py @@ -1,13 +1,14 @@ """Test utilities and constants.""" import re +from typing import ClassVar class TestConstants: - CANVAS_START_POS = {"x": 25, "y": 25} - CANVAS_END_POS = {"x": 75, "y": 75} - CANVAS_CENTER_POS = {"x": 400, "y": 300} - CANVAS_EMPTY_POS = {"x": 100, "y": 100} + CANVAS_START_POS: ClassVar[dict[str, int]] = {"x": 25, "y": 25} + CANVAS_END_POS: ClassVar[dict[str, int]] = {"x": 75, "y": 75} + CANVAS_CENTER_POS: ClassVar[dict[str, int]] = {"x": 400, "y": 300} + CANVAS_EMPTY_POS: ClassVar[dict[str, int]] = {"x": 100, "y": 100} TIMEOUT_WIDGET_LOAD = 30000 TIMEOUT_BUTTON_CLICK = 10000 TIMEOUT_BUTTON_STATE = 5000 @@ -27,12 +28,12 @@ def validate_geographic_bounds(bounds): return True return ( - -180 <= min_lon <= 180 and - -180 <= max_lon <= 180 and - -90 <= min_lat <= 90 and - -90 <= max_lat <= 90 and - min_lon < max_lon and - min_lat < max_lat + -180 <= min_lon <= 180 + and -180 <= max_lon <= 180 + and -90 <= min_lat <= 90 + and -90 <= max_lat <= 90 + and min_lon < max_lon + and min_lat < max_lat ) @@ -50,9 +51,12 @@ def validate_geographic_data(data_text: str | None): min_lon, min_lat, max_lon, max_lat = map(float, data_match.groups()) assert all(coord != 0 for coord in (min_lon, min_lat, max_lon, max_lat)) - assert min_lon < max_lon and min_lat < max_lat - assert -180 <= min_lon <= 180 and -180 <= max_lon <= 180 - assert -90 <= min_lat <= 90 and -90 <= max_lat <= 90 + assert min_lon < max_lon + assert min_lat < max_lat + assert -180 <= min_lon <= 180 + assert -180 <= max_lon <= 180 + assert -90 <= min_lat <= 90 + assert -90 <= max_lat <= 90 def draw_bbox_on_canvas(page_session, start_pos=None, end_pos=None): @@ -95,9 +99,9 @@ def verify_bbox_cleared(sample_map): def get_button(page_session, button_type: str): """Get toolbar button by type.""" button_selectors = { - 'select': 'button[aria-label*="Select"]', - 'cancel': 'button[aria-label*="Cancel"]', - 'clear': 'button[aria-label*="Clear"]', + "select": 'button[aria-label*="Select"]', + "cancel": 'button[aria-label*="Cancel"]', + "clear": 'button[aria-label*="Clear"]', } if button_type not in button_selectors: @@ -144,4 +148,4 @@ def click_feature_position(page_session, position_type: str = "center"): canvas = page_session.locator("canvas").first canvas.click(position=positions[position_type]) - page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) \ No newline at end of file + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) From 8006de18a6e79f67bb48c0d1b5e6dad12b7f23f1 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Mon, 10 Nov 2025 13:13:56 +0000 Subject: [PATCH 4/5] refactor: improve state management organization and documentation - Move state files to dedicated src/state/ directory for better organization - Clean up and standardize JSDoc comments across state management files - Add clear separation between client-side (Zustand) and Python-synced state - Improve inline comments in index.tsx for better code readability - Remove redundant documentation, rely on concise code comments --- DEVELOP.md | 2 -- src/index.tsx | 26 +++++++++++++++---- src/state.ts | 30 --------------------- src/state/index.ts | 10 +++++++ src/state/python-sync.ts | 56 ++++++++++++++++++++++++++++++++++++++++ src/{ => state}/store.ts | 24 +++++++++++++++-- src/toolbar.tsx | 2 +- 7 files changed, 110 insertions(+), 40 deletions(-) delete mode 100644 src/state.ts create mode 100644 src/state/index.ts create mode 100644 src/state/python-sync.ts rename src/{ => state}/store.ts (67%) diff --git a/DEVELOP.md b/DEVELOP.md index 45bed095..70080be8 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -67,8 +67,6 @@ All models on the TypeScript side are combined into a single entry point, which Anywidget and its dependency ipywidgets handles the serialization from Python into JS, automatically keeping each side in sync. -State management is implemented using [XState](https://stately.ai/docs/xstate). The app is instrumented with [Stately Inspector](https://stately.ai/docs/inspector), and the use of the [VS Code extension](https://stately.ai/docs/xstate-vscode-extension) is highly recommended. - ## Publishing Push a new tag to the main branch of the format `v*`. A new version will be published to PyPI automatically. diff --git a/src/index.tsx b/src/index.tsx index 81c28fd8..3537c7d2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,8 +29,7 @@ import { OverlayRendererProps, } from "./renderers/types.js"; import SidePanel from "./sidepanel/index"; -import { useViewStateDebounced } from "./state"; -import { useStore } from "./store"; +import { useStore, useViewStateDebounced } from "./state"; import Toolbar from "./toolbar.js"; import { getTooltip } from "./tooltip/index.js"; import { Message } from "./types.js"; @@ -42,11 +41,19 @@ import "./globals.css"; await initParquetWasm(); function App() { + // ========================================================================= + // Client-Side State (Zustand) + // UI-only state that never syncs with Python + // See: src/state/store.ts + // ========================================================================= + + // Feature highlighting const highlightedFeature = useStore((state) => state.highlightedFeature); const setHighlightedFeature = useStore( (state) => state.setHighlightedFeature, ); + // Bounding box selection const isDrawingBbox = useStore((state) => state.isDrawingBbox); const bboxSelectStart = useStore((state) => state.bboxSelectStart); const bboxSelectEnd = useStore((state) => state.bboxSelectEnd); @@ -113,15 +120,24 @@ function App() { const model = useModel(); + // ============================================================================ + // Python-Synced State (Backbone models) + // ============================================================================ + // State that bidirectionally syncs with Python via Jupyter widgets + // See: src/state/python-sync.ts + + // Map configuration const [basemapModelId] = useModelState("basemap"); const [mapHeight] = useModelState("height"); - const [showTooltip] = useModelState("show_tooltip"); - const [showSidePanel] = useModelState("show_side_panel"); - const [pickingRadius] = useModelState("picking_radius"); const [useDevicePixels] = useModelState( "use_device_pixels", ); const [parameters] = useModelState("parameters"); + + // UI settings + const [showTooltip] = useModelState("show_tooltip"); + const [showSidePanel] = useModelState("show_side_panel"); + const [pickingRadius] = useModelState("picking_radius"); const [customAttribution] = useModelState("custom_attribution"); const [mapId] = useState(uuidv4()); const [childLayerIds] = useModelState("layers"); diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index 29e4263d..00000000 --- a/src/state.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useModel } from "@anywidget/react"; -import type { AnyModel } from "@anywidget/types"; -import debounce from "lodash.debounce"; -import * as React from "react"; - -const debouncedModelSaveChanges = debounce((model: AnyModel) => { - model.save_changes(); -}, 300); - -// TODO: add a `wait` parameter here, instead of having it hard-coded? -export function useViewStateDebounced(key: string): [T, (value: T) => void] { - const model = useModel(); - const [value, setValue] = React.useState(model.get(key)); - React.useEffect(() => { - const callback = () => { - setValue(model.get(key)); - }; - model.on(`change:${key}`, callback); - return () => model.off(`change:${key}`, callback); - }, [model, key]); - return [ - value, - (value) => { - model.set(key, value); - // Note: I think this has to be defined outside of this function so that - // you're calling debounce on the same function object? - debouncedModelSaveChanges(model); - }, - ]; -} diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 00000000..815dda22 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1,10 @@ +/** + * State management for Lonboard. + * + * Two types of state: + * - Client-only: UI state that never syncs with Python (Zustand) + * - Python-synced: State that bidirectionally syncs with Python (Backbone) + */ + +export { useStore } from "./store.js"; +export { useViewStateDebounced } from "./python-sync.js"; diff --git a/src/state/python-sync.ts b/src/state/python-sync.ts new file mode 100644 index 00000000..b4333f8c --- /dev/null +++ b/src/state/python-sync.ts @@ -0,0 +1,56 @@ +/** + * Python ↔ JavaScript state synchronization via Jupyter widgets. + * + * Provides hooks for state that requires bidirectional synchronization with Python + * through Backbone models. Use this for data that Python needs to know about. + */ +import { useModel } from "@anywidget/react"; +import type { AnyModel } from "@anywidget/types"; +import debounce from "lodash.debounce"; +import * as React from "react"; + +/** Debounce save operations to prevent WebSocket flooding (300ms delay) */ +const debouncedModelSaveChanges = debounce((model: AnyModel) => { + model.save_changes(); +}, 300); + +/** + * Bidirectional state sync with Python using debounced updates. + * + * Python → JS: Immediate updates when Python changes the value + * JS → Python: Debounced updates (300ms) to prevent flooding WebSocket + * + * @template T - Type of the state value + * @param key - Model attribute name to sync (e.g., "view_state") + * @returns [value, setValue] - Current value and debounced setter + * + * @example + * ```ts + * // Sync camera view state with Python + * const [viewState, setViewState] = useViewStateDebounced("view_state"); + * + * // Updates from Python appear immediately + * // Updates to Python are debounced + * setViewState({ longitude: -122.4, latitude: 37.8, zoom: 12 }); + * ``` + */ +export function useViewStateDebounced(key: string): [T, (value: T) => void] { + const model = useModel(); + const [value, setValue] = React.useState(model.get(key)); + + // Listen for Python → JS changes + React.useEffect(() => { + const callback = () => setValue(model.get(key)); + model.on(`change:${key}`, callback); + return () => model.off(`change:${key}`, callback); + }, [model, key]); + + // Return JS → Python debounced setter + return [ + value, + (newValue) => { + model.set(key, newValue); + debouncedModelSaveChanges(model); + }, + ]; +} diff --git a/src/store.ts b/src/state/store.ts similarity index 67% rename from src/store.ts rename to src/state/store.ts index f3dc9a52..43170510 100644 --- a/src/store.ts +++ b/src/state/store.ts @@ -1,12 +1,27 @@ +/** + * Client-side UI state management with Zustand. + * + * Manages ephemeral UI state that never syncs with Python. For Python-synced state, + * use Backbone models via `useViewStateDebounced()` from `./python-sync.ts`. + */ import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; import { create } from "zustand"; +/** Shape of the client-side UI state. All properties are UI-only. */ interface AppState { + /** Feature currently highlighted by hover or click interaction */ highlightedFeature: GeoArrowPickingInfo | undefined; + /** Update the highlighted feature */ setHighlightedFeature: (feature: GeoArrowPickingInfo | undefined) => void; + + /** Start coordinate for bounding box selection [lng, lat] */ bboxSelectStart: number[] | undefined; + /** End coordinate for bounding box selection [lng, lat] */ bboxSelectEnd: number[] | undefined; + /** Whether user is currently drawing a bounding box */ isDrawingBbox: boolean; + + /** Actions for bounding box selection */ startBboxSelection: () => void; cancelBboxSelection: () => void; clearBboxSelection: () => void; @@ -16,11 +31,16 @@ interface AppState { } export const useStore = create((set) => ({ + // Feature highlighting highlightedFeature: undefined, setHighlightedFeature: (feature) => set({ highlightedFeature: feature }), + + // Bounding box selection state bboxSelectStart: undefined, bboxSelectEnd: undefined, isDrawingBbox: false, + + // Bounding box actions startBboxSelection: () => set({ isDrawingBbox: true, @@ -38,7 +58,7 @@ export const useStore = create((set) => ({ setBboxStart: (coordinate) => set({ bboxSelectStart: coordinate }), setBboxEnd: (coordinate) => set((state) => { - // Only finish drawing if a start point has been set + // Complete selection only if we have a start point if (state.bboxSelectStart) { return { bboxSelectEnd: coordinate, isDrawingBbox: false }; } @@ -46,7 +66,7 @@ export const useStore = create((set) => ({ }), setBboxHover: (coordinate) => set((state) => { - // Only update hover if we're drawing and have a start point + // Show preview while drawing if (state.isDrawingBbox && state.bboxSelectStart) { return { bboxSelectEnd: coordinate }; } diff --git a/src/toolbar.tsx b/src/toolbar.tsx index 79e7e887..e904ea54 100644 --- a/src/toolbar.tsx +++ b/src/toolbar.tsx @@ -2,7 +2,7 @@ import { Button, ButtonGroup, Tooltip } from "@nextui-org/react"; import React from "react"; import { SquareIcon, XMarkIcon } from "./icons"; -import { useStore } from "./store"; +import { useStore } from "./state"; const Toolbar: React.FC = () => { const isDrawingBbox = useStore((state) => state.isDrawingBbox); From 2ada35fb995fd16078f2ab21b1d034db0d3e3481 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 13 Nov 2025 17:55:25 +0000 Subject: [PATCH 5/5] feat: increate test coverage to include tooltip hover --- tests/ui/conftest.py | 18 + tests/ui/test_feature_highlighting.py | 62 --- tests/ui/test_feature_interaction.py | 676 ++++++++++++++++++++++++++ tests/ui/test_utils.py | 60 +++ 4 files changed, 754 insertions(+), 62 deletions(-) delete mode 100644 tests/ui/test_feature_highlighting.py create mode 100644 tests/ui/test_feature_interaction.py diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 13b9ef32..cd5aae18 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -47,3 +47,21 @@ def sample_map(sample_layer): def sample_map_with_side_panel(sample_layer): """Lonboard map with side panel enabled for testing.""" return Map(sample_layer, show_side_panel=True) + + +@pytest.fixture +def sample_map_with_tooltip(sample_layer): + """Lonboard map with tooltip enabled for testing.""" + return Map(sample_layer, show_tooltip=True, show_side_panel=False) + + +@pytest.fixture +def sample_map_with_tooltip_and_panel(sample_layer): + """Lonboard map with both tooltip and side panel enabled for testing.""" + return Map(sample_layer, show_tooltip=True, show_side_panel=True) + + +@pytest.fixture +def sample_map_with_no_tooltip_or_panel(sample_layer): + """Lonboard map with both tooltip and side panel disabled for testing.""" + return Map(sample_layer, show_tooltip=False, show_side_panel=False) diff --git a/tests/ui/test_feature_highlighting.py b/tests/ui/test_feature_highlighting.py deleted file mode 100644 index 8e88c5ec..00000000 --- a/tests/ui/test_feature_highlighting.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Feature highlighting tests.""" - -import pytest -from test_utils import ( - TestConstants, - click_feature_position, - setup_map_widget, - start_bbox_selection, - wait_for_button, -) - - -@pytest.mark.usefixtures("solara_test") -class TestFeatureHighlighting: - """Test feature highlighting.""" - - def test_feature_click_stability(self, page_session, sample_map_with_side_panel): - canvas = setup_map_widget(page_session, sample_map_with_side_panel) - - # Click center position (might hit a feature) - click_feature_position(page_session, "center") - assert canvas.count() > 0 - - def test_empty_space_click(self, page_session, sample_map_with_side_panel): - canvas = setup_map_widget(page_session, sample_map_with_side_panel) - - # Click empty space - click_feature_position(page_session, "empty") - assert canvas.count() > 0 - - def test_multiple_clicks_stability(self, page_session, sample_map_with_side_panel): - canvas = setup_map_widget(page_session, sample_map_with_side_panel) - - # Multiple rapid clicks - for position in ["empty", "center", "empty", "start", "empty"]: - click_feature_position(page_session, position) - assert canvas.count() > 0 - page_session.wait_for_timeout(200) - - def test_feature_highlighting_with_bbox_mode( - self, - page_session, - sample_map_with_side_panel, - ): - canvas = setup_map_widget(page_session, sample_map_with_side_panel) - - # Start bbox mode - start_bbox_selection(page_session) - cancel_button = wait_for_button(page_session, "cancel") - assert cancel_button.count() > 0 - - # Click feature while in bbox mode - click_feature_position(page_session, "center") - assert canvas.count() > 0 - - # Cancel bbox mode - cancel_button.click() - page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) - - # Verify return to normal - select_button = wait_for_button(page_session, "select") - assert select_button.count() > 0 diff --git a/tests/ui/test_feature_interaction.py b/tests/ui/test_feature_interaction.py new file mode 100644 index 00000000..f0da3a5e --- /dev/null +++ b/tests/ui/test_feature_interaction.py @@ -0,0 +1,676 @@ +""" +Feature Interaction Tests for Lonboard UI + +This module tests the complete feature interaction system, which includes: +- Hover interactions (tooltips and highlighting) +- Click interactions (side panel display) +- Mixed interaction patterns +- Configuration and property validation +- Integration with other UI modes (bbox selection) + +The feature interaction system allows users to explore geographic data through: +1. **Tooltips**: Hover-based information display (when show_tooltip=True) +2. **Side Panel**: Click-based detailed information display (when show_side_panel=True) +3. **Feature Highlighting**: Visual feedback on hover/click regardless of display mode + +Both tooltip and side panel present the same underlying feature data, just in different formats. +""" + +import sys +from pathlib import Path + +import geopandas as gpd +import pytest +from IPython.display import display +from shapely.geometry import Point +from test_utils import ( + TestConstants, + check_tooltip_visibility, + click_feature_position, + get_tooltip_content, + get_tooltip_position, + hover_feature_position, + setup_map_widget, + start_bbox_selection, + wait_for_button, +) + +from lonboard import Map +from lonboard.layer import ScatterplotLayer + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def feature_test_geodataframe(): + """ + GeoDataFrame with rich data for feature interaction testing. + + Contains multiple data types (text, numbers, categories) to ensure + the feature interaction system handles various data formats correctly. + """ + return gpd.GeoDataFrame( + { + "name": ["test_point_1", "test_point_2", "test_point_3"], + "value": [100, 200, 300], + "category": ["A", "B", "C"], + "description": [ + "First test point", + "Second test point", + "Third test point", + ], + "coordinates": [(0, 0), (1, 1), (-1, -1)], + "priority": [1, 2, 3], + }, + geometry=[Point(0, 0), Point(1, 1), Point(-1, -1)], + crs="EPSG:4326", + ) + + +@pytest.fixture +def feature_test_layer(feature_test_geodataframe): + """ + Scatterplot layer configured for comprehensive feature interaction testing. + + The layer is explicitly configured to be pickable and has sufficient + radius to ensure reliable hover and click interactions during testing. + """ + return ScatterplotLayer.from_geopandas( + feature_test_geodataframe, + get_fill_color=[255, 0, 0], + get_radius=5000, + pickable=True, # Essential for hover and click interactions + ) + + +# ============================================================================= +# VALIDATION UTILITIES +# ============================================================================= + + +def validate_feature_configuration( + map_obj, + expected_show_tooltip, + expected_show_side_panel, +): + """ + Validate that map has correct feature interaction configuration. + + Args: + map_obj: Map instance to validate + expected_show_tooltip: Expected show_tooltip value + expected_show_side_panel: Expected show_side_panel value + """ + assert hasattr(map_obj, "show_tooltip"), "Map should have show_tooltip attribute" + assert hasattr(map_obj, "show_side_panel"), ( + "Map should have show_side_panel attribute" + ) + + assert map_obj.show_tooltip == expected_show_tooltip, ( + f"Expected show_tooltip={expected_show_tooltip}, got {map_obj.show_tooltip}" + ) + assert map_obj.show_side_panel == expected_show_side_panel, ( + f"Expected show_side_panel={expected_show_side_panel}, got {map_obj.show_side_panel}" + ) + + +def validate_map_display(map_obj): + """ + Validate that map can be displayed without errors. + + This is important because feature interactions only work on properly + rendered maps. + """ + try: + display(map_obj) + except (OSError, ValueError, RuntimeError) as e: + pytest.fail(f"Map display failed with error: {e}") + else: + return True + + +# ============================================================================= +# PYTHON PROPERTY TESTS +# ============================================================================= + + +class TestFeatureInteractionConfiguration: + """ + Test the Python properties that control feature interaction behavior. + + These tests focus on the configuration aspects without requiring + browser interaction, making them fast and suitable for CI/CD. + + Configuration Options: + - show_tooltip: Enables hover-based tooltip display + - show_side_panel: Enables click-based side panel display + - Both can be enabled simultaneously or independently + """ + + def test_tooltip_enabled_configuration(self, sample_map_with_tooltip): + """ + Test that show_tooltip=True properly configures tooltip interactions. + + Verifies that the map can be created with tooltips enabled and + displays without errors. + """ + validate_feature_configuration( + sample_map_with_tooltip, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(sample_map_with_tooltip) + + def test_tooltip_disabled_configuration(self, sample_map_with_no_tooltip_or_panel): + """ + Test that show_tooltip=False disables tooltip interactions. + + Ensures that maps can be created with tooltips explicitly disabled + and that the configuration persists correctly. + """ + validate_feature_configuration( + sample_map_with_no_tooltip_or_panel, + expected_show_tooltip=False, + expected_show_side_panel=False, + ) + validate_map_display(sample_map_with_no_tooltip_or_panel) + + def test_side_panel_enabled_configuration(self, sample_map_with_side_panel): + """ + Test that show_side_panel=True enables click-based interactions. + + Validates that the side panel configuration works correctly + for feature selection on click. + """ + validate_feature_configuration( + sample_map_with_side_panel, + expected_show_tooltip=False, + expected_show_side_panel=True, + ) + validate_map_display(sample_map_with_side_panel) + + def test_both_enabled_configuration(self, sample_map_with_tooltip_and_panel): + """ + Test that both tooltip and side panel can work simultaneously. + + This is an important test as it ensures that users can have + both hover tooltips and click side panels enabled at the same time. + """ + validate_feature_configuration( + sample_map_with_tooltip_and_panel, + expected_show_tooltip=True, + expected_show_side_panel=True, + ) + validate_map_display(sample_map_with_tooltip_and_panel) + + def test_default_configuration(self, feature_test_layer): + """ + Test default behavior when no explicit configuration is provided. + + Lonboard should default to no tooltips but side panel enabled, + providing the traditional click-to-explore behavior. + """ + default_map = Map(feature_test_layer, height="400px") + validate_feature_configuration( + default_map, + expected_show_tooltip=False, + expected_show_side_panel=True, + ) + + def test_configuration_persistence(self, feature_test_layer): + """ + Test that configuration persists after map operations. + + Ensures that the feature interaction settings remain consistent + after the map is displayed and potentially manipulated. + """ + map_obj = Map( + feature_test_layer, + show_tooltip=True, + show_side_panel=False, + height="400px", + ) + + # Verify initial state + assert map_obj.show_tooltip is True + assert map_obj.show_side_panel is False + + # Display map (simulates user interaction) + validate_map_display(map_obj) + + # Verify properties are still correct after display + assert map_obj.show_tooltip is True + assert map_obj.show_side_panel is False + + def test_configuration_with_different_layers(self, feature_test_geodataframe): + """ + Test that configuration works consistently across different layer types. + + Ensures that feature interaction configuration is not dependent + on specific layer properties or styling. + """ + configs = [ + {"get_fill_color": [255, 0, 0], "get_radius": 5000}, + {"get_fill_color": [0, 255, 0], "get_radius": 3000}, + {"get_fill_color": [0, 0, 255], "get_radius": 7000}, + ] + + for config in configs: + layer = ScatterplotLayer.from_geopandas(feature_test_geodataframe, **config) + map_obj = Map( + layer, + show_tooltip=True, + show_side_panel=False, + height="300px", + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(map_obj) + + @pytest.mark.usefixtures("feature_test_layer") + def test_configuration_edge_cases(self): + """ + Test edge cases and potential error conditions in configuration. + + Ensures that the system handles unusual configurations gracefully + and maintains expected behavior. + """ + # Test with various GeoDataFrame structures + test_cases = [ + # Single point + gpd.GeoDataFrame( + {"single_value": [42]}, + geometry=[Point(0, 0)], + crs="EPSG:4326", + ), + # Multiple data types + gpd.GeoDataFrame( + { + "text": ["A", "B"], + "number": [1.5, 2.7], + "integer": [10, 20], + "boolean": [True, False], + }, + geometry=[Point(0, 0), Point(1, 1)], + crs="EPSG:4326", + ), + # Minimal properties + gpd.GeoDataFrame( + geometry=[Point(0, 0)], + crs="EPSG:4326", + ), + ] + + for gdf in test_cases: + layer = ScatterplotLayer.from_geopandas( + gdf, + get_fill_color=[255, 0, 0], + get_radius=5000, + ) + map_obj = Map( + layer, + show_tooltip=True, + show_side_panel=False, + height="300px", + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(map_obj) + + +# ============================================================================= +# BROWSER INTERACTION TESTS +# ============================================================================= + + +@pytest.mark.usefixtures("solara_test") +class TestFeatureInteractionBehavior: + """ + Test actual user interactions with features in a browser environment. + + These tests use Playwright to simulate real user interactions including + hover events, clicks, and mixed interaction patterns. They verify that + the feature interaction system responds correctly to user input. + + Interaction Types Tested: + - Hover: Triggers tooltips and visual highlighting + - Click: Triggers side panel display and feature selection + - Mixed: Ensures hover and click interactions work together + """ + + def test_tooltip_hover_behavior(self, page_session, sample_map_with_tooltip): + """ + Test tooltip appearance and behavior on hover when show_tooltip=True. + + This test verifies that: + - Tooltips appear when hovering over features + - Tooltips disappear when hovering over empty areas + - Tooltip content and positioning are reasonable when visible + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Hover over center position (should have features) + hover_feature_position(page_session, "center") + + # Check if tooltip appears + tooltip_visible = check_tooltip_visibility(page_session, should_be_visible=True) + + # Tooltip might not appear if no feature is at the exact position + # The important thing is that no errors occur and the system responds to hover + if tooltip_visible: + # Verify tooltip content and positioning when visible + content = get_tooltip_content(page_session) + position = get_tooltip_position(page_session) + + assert content is not None, "Tooltip should have content when visible" + assert position is not None, "Tooltip should have position when visible" + assert position["width"] > 0, "Tooltip should have width" + assert position["height"] > 0, "Tooltip should have height" + + # Hover over empty area (should not show tooltip) + hover_feature_position(page_session, "empty") + + # Tooltip should disappear or remain hidden + check_tooltip_visibility(page_session, should_be_visible=False) + + assert canvas.count() > 0, "Map canvas should remain loaded" + + def test_no_tooltip_when_disabled( + self, + page_session, + sample_map_with_no_tooltip_or_panel, + ): + """ + Test that tooltips never appear when show_tooltip=False. + + This ensures that the tooltip system is properly disabled + and doesn't interfere with other interactions. + """ + canvas = setup_map_widget(page_session, sample_map_with_no_tooltip_or_panel) + + # Hover over different positions + for position in ["center", "start", "end"]: + hover_feature_position(page_session, position) + + # Tooltip should never be visible when disabled + tooltip_hidden = check_tooltip_visibility( + page_session, + should_be_visible=False, + ) + assert tooltip_hidden, ( + f"Tooltip should not be visible when show_tooltip=False (hovered at {position})" + ) + + assert canvas.count() > 0, "Map canvas should remain loaded" + + def test_side_panel_click_behavior(self, page_session, sample_map_with_side_panel): + """ + Test side panel behavior when show_side_panel=True. + + Verifies that clicking on features properly triggers side panel + display and that the system remains stable. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Click on different positions + for position in ["center", "start", "end"]: + click_feature_position(page_session, position) + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # System should handle clicks gracefully + assert canvas.count() > 0, ( + f"Map canvas should remain stable after click at {position}" + ) + + def test_mixed_hover_and_click_interactions( + self, + page_session, + sample_map_with_tooltip_and_panel, + ): + """ + Test behavior when both tooltip and side panel are enabled. + + This is a critical test that ensures hover tooltips and click + side panels can coexist without interfering with each other. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip_and_panel) + + # Test hover first + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Then test click + click_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Test hover again after click + hover_feature_position(page_session, "empty") + + # System should remain stable after both hover and click interactions + assert canvas.count() > 0, ( + "Map canvas should remain loaded after mixed interactions" + ) + + def test_hover_before_click_stability( + self, + page_session, + sample_map_with_side_panel, + ): + """ + Test that hovering before clicking doesn't interfere with feature selection. + + Ensures that hover state doesn't affect subsequent click behavior, + which is important for smooth user experience. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Hover first, then click + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + click_feature_position(page_session, "center") + assert canvas.count() > 0 + + def test_multiple_interaction_patterns(self, page_session, sample_map_with_tooltip): + """ + Test complex patterns of hover and click interactions. + + Simulates realistic user behavior with rapid interaction changes + to ensure the system remains stable and responsive. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Test pattern: hover -> hover -> click -> hover -> hover + interactions = [ + ("hover", "center"), + ("hover", "start"), + ("click", "center"), + ("hover", "empty"), + ("hover", "end"), + ] + + for action, position in interactions: + if action == "hover": + hover_feature_position(page_session, position) + else: + click_feature_position(page_session, position) + + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + assert canvas.count() > 0, ( + f"Map canvas should remain stable after {action} at {position}" + ) + + def test_feature_interaction_with_bbox_mode( + self, + page_session, + sample_map_with_side_panel, + ): + """ + Test feature interactions during bbox selection mode. + + Ensures that feature highlighting and selection work correctly + even when the map is in a different interaction mode. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Start bbox mode + start_bbox_selection(page_session) + cancel_button = wait_for_button(page_session, "cancel") + assert cancel_button.count() > 0 + + # Test hover during bbox mode + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Test click during bbox mode + click_feature_position(page_session, "center") + assert canvas.count() > 0 + + # Cancel bbox mode + cancel_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Verify return to normal feature interaction mode + select_button = wait_for_button(page_session, "select") + assert select_button.count() > 0 + + def test_tooltip_positioning_and_styling( + self, + page_session, + sample_map_with_tooltip, + ): + """ + Test tooltip positioning and visual properties when visible. + + Validates that tooltips appear at reasonable positions + with appropriate sizing for good user experience. + """ + # Set up map widget for tooltip positioning test + setup_map_widget(page_session, sample_map_with_tooltip) + + # Hover over areas that might have features + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(500) + + # Check if tooltip is visible + if check_tooltip_visibility(page_session, should_be_visible=True): + position = get_tooltip_position(page_session) + + # Verify tooltip positioning makes sense + assert position is not None, "Should have tooltip position when visible" + assert position["x"] >= 0, "Tooltip x position should be non-negative" + assert position["y"] >= 0, "Tooltip y position should be non-negative" + assert position["width"] > 0, "Tooltip should have visible width" + assert position["height"] > 0, "Tooltip should have visible height" + + # Tooltip should be reasonably sized + assert 50 <= position["width"] <= 500, "Tooltip width should be reasonable" + assert 20 <= position["height"] <= 300, ( + "Tooltip height should be reasonable" + ) + + +# ============================================================================= +# INTEGRATION TESTS +# ============================================================================= + + +@pytest.mark.usefixtures("solara_test") +class TestFeatureInteractionIntegration: + """ + Test feature interaction integration with other map features and modes. + + These tests ensure that the feature interaction system works correctly + alongside other Lonboard features like bbox selection and rapid user actions. + """ + + def test_interaction_stability_under_rapid_actions( + self, + page_session, + sample_map_with_tooltip, + ): + """ + Test system stability under rapid user interactions. + + Simulates a user rapidly moving the mouse and clicking to ensure + the feature interaction system doesn't become unstable or crash. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Rapid interaction pattern + positions = ["center", "start", "empty", "end", "center", "empty"] + + for _ in range(3): # Repeat the pattern multiple times + for position in positions: + hover_feature_position(page_session, position) + page_session.wait_for_timeout(100) # Very rapid + + # Final stability check + assert canvas.count() > 0, "Map should remain stable after rapid interactions" + + +# ============================================================================= +# EDGE CASES AND ERROR HANDLING +# ============================================================================= + + +def test_feature_interaction_configuration_edge_cases(feature_test_layer): + """ + Test edge cases in feature interaction configuration. + + These tests ensure that unusual or boundary conditions are handled + gracefully without causing errors or unexpected behavior. + """ + # Test with boolean values explicitly + map_true = Map(feature_test_layer, show_tooltip=True) + map_false = Map(feature_test_layer, show_tooltip=False) + + assert map_true.show_tooltip is True + assert map_false.show_tooltip is False + + # Test property access as attributes + assert hasattr(map_true, "show_tooltip") + assert hasattr(map_false, "show_tooltip") + + # Test property types + assert isinstance(map_true.show_tooltip, bool) + assert isinstance(map_false.show_tooltip, bool) + + +def test_feature_interaction_with_map_configuration(feature_test_layer): + """ + Test feature interaction properties alongside other Map configurations. + + Ensures that feature interaction settings work correctly with + other map parameters like picking radius, height, etc. + """ + # Test with multiple map parameters + map_obj = Map( + feature_test_layer, + show_tooltip=True, + show_side_panel=False, + height="500px", + picking_radius=10, + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + assert map_obj.height == "500px" + assert map_obj.picking_radius == 10 diff --git a/tests/ui/test_utils.py b/tests/ui/test_utils.py index a28409eb..04aa8bb3 100644 --- a/tests/ui/test_utils.py +++ b/tests/ui/test_utils.py @@ -149,3 +149,63 @@ def click_feature_position(page_session, position_type: str = "center"): canvas = page_session.locator("canvas").first canvas.click(position=positions[position_type]) page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + +def hover_feature_position(page_session, position_type: str = "center"): + """Hover on predefined canvas position.""" + positions = { + "center": TestConstants.CANVAS_CENTER_POS, + "empty": TestConstants.CANVAS_EMPTY_POS, + "start": TestConstants.CANVAS_START_POS, + "end": TestConstants.CANVAS_END_POS, + } + + if position_type not in positions: + raise ValueError(f"Unknown position type: {position_type}") + + canvas = page_session.locator("canvas").first + canvas.hover(position=positions[position_type]) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + +def check_tooltip_visibility(page_session, *, should_be_visible: bool = True): + """Check if tooltip is visible in the DOM.""" + # Look for tooltip element with the specific CSS class + tooltip = page_session.locator(".lonboard-tooltip") + + if should_be_visible: + # Wait for tooltip to appear and be visible + try: + tooltip.wait_for(timeout=2000, state="visible") + return tooltip.count() > 0 and tooltip.is_visible() + except (TimeoutError, AssertionError): + return False + else: + # Check that tooltip is not visible or doesn't exist + # We just need to check current state, not wait for it to become hidden + count = tooltip.count() + if count == 0: + return True # No tooltip exists, which is correct + return not tooltip.is_visible() # Tooltip exists but is hidden + + +def get_tooltip_content(page_session): + """Get tooltip content if visible.""" + tooltip = page_session.locator(".lonboard-tooltip") + if tooltip.count() > 0 and tooltip.is_visible(): + return tooltip.inner_text() + return None + + +def get_tooltip_position(page_session): + """Get tooltip bounding box if visible.""" + tooltip = page_session.locator(".lonboard-tooltip") + if tooltip.count() > 0 and tooltip.is_visible(): + bbox = tooltip.bounding_box() + return { + "x": bbox["x"], + "y": bbox["y"], + "width": bbox["width"], + "height": bbox["height"], + } + return None