diff --git a/app.py b/app.py index bf5ab5d..ca3f3f2 100644 --- a/app.py +++ b/app.py @@ -151,9 +151,10 @@ def run(self): # Show saved connections in the left panel self.table_browser_ui.show_saved_connections() - # Manual render loop: drain async UI callbacks each frame so background + # Manual render loop: process async UI callbacks each frame so background # threads can safely update the UI without thread-safety issues. while is_dearpygui_running(): + # Keep expensive result-grid callbacks within a small per-frame budget. self.async_worker.process_pending() self.table_browser_ui.process_pending_filter() self.ui_layout.splitter.update() diff --git a/async_worker.py b/async_worker.py index 9e37af9..0e39443 100644 --- a/async_worker.py +++ b/async_worker.py @@ -9,6 +9,7 @@ import queue import threading +import time from collections.abc import Callable from typing import Any @@ -67,10 +68,10 @@ def _worker(): try: result = task() if on_done is not None: - self._ui_queue.put(lambda r=result: on_done(r)) + self.post_ui(lambda r=result: on_done(r)) except Exception as exc: if on_error is not None: - self._ui_queue.put(lambda e=exc: on_error(e)) + self.post_ui(lambda e=exc: on_error(e)) finally: with self._lock: self._active -= 1 @@ -79,11 +80,37 @@ def _worker(): t.start() return t - def process_pending(self): - """Drain all pending UI callbacks. Must be called from the main thread each frame.""" - while True: + def post_ui(self, callback: Callable[[], Any]) -> None: + """Schedule a callback for the next available main-thread frame budget.""" + self._ui_queue.put(callback) + + def process_pending( + self, + *, + time_budget_seconds: float | None = 0.004, + max_callbacks: int | None = 8, + ) -> int: + """Run queued UI callbacks without monopolizing the current render frame. + + A callback is never interrupted. The limits are checked between callbacks, + so one expensive callback can exceed the target budget but the remaining + queue will be left for a later frame. + """ + started_at = time.perf_counter() + processed = 0 + + while max_callbacks is None or processed < max_callbacks: try: fn = self._ui_queue.get_nowait() - fn() except queue.Empty: break + fn() + processed += 1 + + if ( + time_budget_seconds is not None + and time.perf_counter() - started_at >= time_budget_seconds + ): + break + + return processed diff --git a/components/query_interface.py b/components/query_interface.py index bb868a9..13adf2f 100644 --- a/components/query_interface.py +++ b/components/query_interface.py @@ -11,7 +11,15 @@ import sqlparse from dearpygui.dearpygui import * -from config import DEFAULT_TAB_LABEL, MAX_QUERY_TABS, QUERY_INPUT_HEIGHT +from config import ( + DEFAULT_TAB_LABEL, + MAX_CELL_LENGTH, + MAX_QUERY_TABS, + MAX_ROWS_LIMIT, + QUERY_INPUT_HEIGHT, + RESULT_PAGE_SIZE, + RESULT_ROWS_PER_FRAME, +) from database import ConnectionPool, DatabaseManager from icon_manager import icon_manager from utils import get_result_column_types @@ -40,10 +48,14 @@ class QueryTabState: query_running: bool = False input_height: int = QUERY_INPUT_HEIGHT + current_result_group_tag: str | None = None current_table_tag: str | None = None table_counter: int = 0 last_query_results: list | None = None last_column_names: list | None = None + last_column_types: dict | None = None + result_page_index: int = 0 + render_generation: int = 0 loading_indicator: str | None = None loading_animation_running: bool = False @@ -476,14 +488,12 @@ def _on_query_done(self, tab_id: int, payload): self._update_loading_message(state, "Processing results...") - if state.current_table_tag and does_item_exist(state.current_table_tag): - delete_item(state.current_table_tag) - state.current_table_tag = None - if not result.result_rows: self._hide_loading(state) state.last_query_results = None state.last_column_names = None + state.last_column_types = None + state.result_page_index = 0 if does_item_exist(state.save_btn_tag): configure_item(state.save_btn_tag, show=False) if self.status_callback: @@ -494,48 +504,18 @@ def _on_query_done(self, tab_id: int, payload): rows = result.result_rows state.last_query_results = rows state.last_column_names = column_names + state.last_column_types = column_types + state.result_page_index = 0 if does_item_exist(state.save_btn_tag): configure_item(state.save_btn_tag, show=True) - if len(rows) > 100: - self._update_loading_message( - state, f"Building table with {len(rows)} rows..." - ) - else: - self._update_loading_message(state, "Building table...") - - self._setup_results_table(state, column_names, column_types) - - for row_idx, row in enumerate(rows): - with table_row(parent=state.current_table_tag): - for col_idx, cell_value in enumerate(row): - formatted_cell = self._format_cell_value(cell_value) - original_cell = ( - str(cell_value) if cell_value is not None else "NULL" - ) - cell_tag = f"query_cell_{state.tab_id}_{state.table_counter}_{row_idx}_{col_idx}" - with table_cell(): - add_input_text( - tag=f"cell_input_{cell_tag}", - default_value=formatted_cell, - readonly=True, - width=-1, - height=0, - no_spaces=False, - tab_input=False, - hint="", - multiline=False, - user_data=original_cell, - ) - - self._hide_loading(state) - - if self.status_callback: - self.status_callback( - f"Query executed successfully. Rows returned: {len(rows)}. Select text and Ctrl+C to copy.", - False, - ) + visible_rows = min(len(rows), MAX_ROWS_LIMIT) + self._update_loading_message( + state, + f"Preparing {visible_rows} display rows...", + ) + self._render_result_page(tab_id) def _on_query_error(self, tab_id: int, e: Exception): """Main-thread callback when query raises an exception.""" @@ -546,6 +526,8 @@ def _on_query_error(self, tab_id: int, e: Exception): self._hide_loading(state) state.last_query_results = None state.last_column_names = None + state.last_column_types = None + state.result_page_index = 0 if does_item_exist(state.save_btn_tag): configure_item(state.save_btn_tag, show=False) if self.status_callback: @@ -555,7 +537,13 @@ def _on_query_error(self, tab_id: int, e: Exception): # Table setup # ------------------------------------------------------------------ - def _setup_results_table(self, state: QueryTabState, columns, column_types=None): + def _setup_results_table( + self, + state: QueryTabState, + columns, + column_types=None, + parent: str | None = None, + ): if column_types is None: column_types = {} @@ -567,7 +555,7 @@ def _setup_results_table(self, state: QueryTabState, columns, column_types=None) add_table( tag=state.current_table_tag, - parent=state.results_window_tag, + parent=parent or state.results_window_tag, borders_innerH=True, borders_innerV=True, borders_outerH=True, @@ -610,16 +598,201 @@ def _setup_results_table(self, state: QueryTabState, columns, column_types=None) except Exception: pass + def _clear_current_results(self, state: QueryTabState) -> None: + """Remove the current result group, including its table and pager.""" + if state.current_result_group_tag and does_item_exist( + state.current_result_group_tag + ): + delete_item(state.current_result_group_tag) + elif state.current_table_tag and does_item_exist(state.current_table_tag): + delete_item(state.current_table_tag) + state.current_result_group_tag = None + state.current_table_tag = None + + def _render_result_page(self, tab_id: int) -> None: + """Create one bounded page and enqueue its rows in small frame chunks.""" + state = self._tabs.get(tab_id) + if state is None or not state.last_query_results or not state.last_column_names: + return + + total_rows = len(state.last_query_results) + display_rows = min(total_rows, MAX_ROWS_LIMIT) + page_count = max(1, (display_rows + RESULT_PAGE_SIZE - 1) // RESULT_PAGE_SIZE) + state.result_page_index = max(0, min(state.result_page_index, page_count - 1)) + page_start = state.result_page_index * RESULT_PAGE_SIZE + page_end = min(page_start + RESULT_PAGE_SIZE, display_rows) + page_rows = state.last_query_results[page_start:page_end] + + state.render_generation += 1 + generation = state.render_generation + self._clear_current_results(state) + self._hide_loading(state) + + next_counter = state.table_counter + 1 + result_group_tag = f"query_result_group_{tab_id}_{next_counter}" + pager_tag = f"query_result_pager_{tab_id}_{next_counter}" + add_group(tag=result_group_tag, parent=state.results_window_tag) + add_group(tag=pager_tag, parent=result_group_tag, horizontal=True) + state.current_result_group_tag = result_group_tag + + previous_tag = f"query_result_previous_{tab_id}_{next_counter}" + next_tag = f"query_result_next_{tab_id}_{next_counter}" + add_button( + label="Previous", + tag=previous_tag, + parent=pager_tag, + enabled=state.result_page_index > 0, + callback=lambda s, d: self._change_result_page(tab_id, -1), + ) + add_text( + f"Rows {page_start + 1}-{page_end} of {display_rows}", + parent=pager_tag, + ) + add_button( + label="Next", + tag=next_tag, + parent=pager_tag, + enabled=state.result_page_index + 1 < page_count, + callback=lambda s, d: self._change_result_page(tab_id, 1), + ) + if total_rows > display_rows: + add_text( + f"Display capped at {display_rows:,} of {total_rows:,} returned rows; export still includes all rows.", + parent=result_group_tag, + color=(255, 193, 7), + ) + + if self.theme_manager: + button_theme = self.theme_manager.get_theme("button_secondary") + bind_item_theme(previous_tag, button_theme) + bind_item_theme(next_tag, button_theme) + + self._setup_results_table( + state, + state.last_column_names, + state.last_column_types, + parent=result_group_tag, + ) + self._queue_result_rows_chunk( + tab_id, + generation, + page_rows, + page_start, + 0, + total_rows, + display_rows, + ) + + def _change_result_page(self, tab_id: int, delta: int) -> None: + state = self._tabs.get(tab_id) + if state is None: + return + state.result_page_index += delta + self._render_result_page(tab_id) + + def _queue_result_rows_chunk( + self, + tab_id: int, + generation: int, + rows, + page_start: int, + chunk_start: int, + total_rows: int, + display_rows: int, + ) -> None: + def callback(): + self._render_result_rows_chunk( + tab_id, + generation, + rows, + page_start, + chunk_start, + total_rows, + display_rows, + ) + + if self.async_worker: + self.async_worker.post_ui(callback) + else: + callback() + + def _render_result_rows_chunk( + self, + tab_id: int, + generation: int, + rows, + page_start: int, + chunk_start: int, + total_rows: int, + display_rows: int, + ) -> None: + """Append a small result-row chunk, then yield to a later frame.""" + state = self._tabs.get(tab_id) + if ( + state is None + or generation != state.render_generation + or not state.current_table_tag + or not does_item_exist(state.current_table_tag) + ): + return + + chunk_end = min(chunk_start + RESULT_ROWS_PER_FRAME, len(rows)) + for page_row_idx in range(chunk_start, chunk_end): + row = rows[page_row_idx] + row_idx = page_start + page_row_idx + with table_row(parent=state.current_table_tag): + for col_idx, cell_value in enumerate(row): + original_cell = self._cell_value_to_text(cell_value) + formatted_cell = self._truncate_cell_text(original_cell) + add_selectable( + label=formatted_cell, + tag=f"query_cell_{state.tab_id}_{state.table_counter}_{row_idx}_{col_idx}", + span_columns=False, + height=0, + callback=self._copy_query_cell, + user_data=original_cell, + ) + + if chunk_end < len(rows): + self._queue_result_rows_chunk( + tab_id, + generation, + rows, + page_start, + chunk_end, + total_rows, + display_rows, + ) + return + + if self.status_callback: + range_end = page_start + len(rows) + capped_suffix = ( + f" Display capped at {display_rows:,}; export includes all rows." + if total_rows > display_rows + else "" + ) + self.status_callback( + f"Query executed successfully. Showing rows {page_start + 1}-{range_end} of {display_rows:,}. Click a cell to copy.{capped_suffix}", + False, + ) + + def _copy_query_cell(self, sender, app_data, user_data) -> None: + value = user_data if user_data is not None else "" + set_clipboard_text(value) + if self.status_callback: + preview = value[:50] + suffix = "..." if len(value) > 50 else "" + self.status_callback(f"Copied to clipboard: {preview}{suffix}", False) + # ------------------------------------------------------------------ # Loading indicator (per-tab) # ------------------------------------------------------------------ def _show_loading(self, state: QueryTabState): self._hide_loading(state) - - if state.current_table_tag and does_item_exist(state.current_table_tag): - delete_item(state.current_table_tag) - state.current_table_tag = None + state.render_generation += 1 + self._clear_current_results(state) ts = int(time.time()) state.loading_indicator = f"loading_{state.tab_id}_{ts}" @@ -767,7 +940,7 @@ def _convert_results_to_json(self, state: QueryTabState): # ------------------------------------------------------------------ @staticmethod - def _format_cell_value(val) -> str: + def _cell_value_to_text(val) -> str: if val is None: return "NULL" elif isinstance(val, bytes): @@ -775,9 +948,17 @@ def _format_cell_value(val) -> str: return val.decode("utf-8", errors="replace") except Exception: return str(val) - elif isinstance(val, str): - return val - return str(val) + return val if isinstance(val, str) else str(val) + + @staticmethod + def _truncate_cell_text(value: str) -> str: + if len(value) > MAX_CELL_LENGTH: + return value[: MAX_CELL_LENGTH - 3] + "..." + return value + + @classmethod + def _format_cell_value(cls, val) -> str: + return cls._truncate_cell_text(cls._cell_value_to_text(val)) @staticmethod def _add_default_limit(query: str) -> str: diff --git a/config.py b/config.py index 71e03db..a7b5e46 100644 --- a/config.py +++ b/config.py @@ -15,6 +15,8 @@ DEFAULT_DATABASE = "default" MAX_ROWS_LIMIT = 1000 DEFAULT_LIMIT = 100 +RESULT_PAGE_SIZE = 100 +RESULT_ROWS_PER_FRAME = 20 MAX_QUERY_TABS = 5 MAX_EXPLORER_TABS = 5 DEFAULT_TAB_LABEL = "Query" diff --git a/data_explorer.py b/data_explorer.py index 181e6cc..36e1025 100644 --- a/data_explorer.py +++ b/data_explorer.py @@ -6,7 +6,12 @@ from dearpygui.dearpygui import * -from config import DEFAULT_LIMIT, MAX_CELL_LENGTH, MAX_EXPLORER_TABS +from config import ( + DEFAULT_LIMIT, + MAX_CELL_LENGTH, + MAX_EXPLORER_TABS, + RESULT_ROWS_PER_FRAME, +) from database import DatabaseManager from utils import get_result_column_types @@ -32,6 +37,8 @@ def __init__( self.sort_column: str | None = None self.sort_ascending: bool = True self._refresh_seq = 0 + self._render_generation = 0 + self._active_table_tag: str | None = None self._last_status_callback = None # Per-instance widget tags derived from prefix @@ -222,6 +229,8 @@ def refresh_data(self, status_callback=None): # Bump sequence number — any in-flight fetch with an older seq will be discarded self._refresh_seq += 1 seq = self._refresh_seq + self._render_generation += 1 + self._active_table_tag = None # Show placeholder immediately on the main thread if does_item_exist(self.main_table_tag): @@ -300,6 +309,7 @@ def _on_data_ready(self, payload, seq: int, status_callback=None): scrollX=True, scrollY=True, freeze_rows=1, + clipper=True, height=-1, resizable=True, policy=mvTable_SizingFixedFit, @@ -313,6 +323,8 @@ def _on_data_ready(self, payload, seq: int, status_callback=None): ) return + self._active_table_tag = table_tag + if self.table_theme: bind_item_theme(table_tag, self.table_theme) @@ -359,8 +371,55 @@ def _on_data_ready(self, payload, seq: int, status_callback=None): height=50, ) - # Data rows - for row_idx, row in enumerate(result.result_rows): + self._queue_data_rows_chunk( + table_tag, + result.result_rows, + 0, + self._render_generation, + status_callback, + ) + + def _queue_data_rows_chunk( + self, + table_tag: str, + rows, + chunk_start: int, + generation: int, + status_callback=None, + ) -> None: + def callback(): + self._render_data_rows_chunk( + table_tag, + rows, + chunk_start, + generation, + status_callback, + ) + + if self.async_worker: + self.async_worker.post_ui(callback) + else: + callback() + + def _render_data_rows_chunk( + self, + table_tag: str, + rows, + chunk_start: int, + generation: int, + status_callback=None, + ) -> None: + """Append a bounded Explorer row chunk, then yield to a later frame.""" + if ( + generation != self._render_generation + or table_tag != self._active_table_tag + or not does_item_exist(table_tag) + ): + return + + chunk_end = min(chunk_start + RESULT_ROWS_PER_FRAME, len(rows)) + for row_idx in range(chunk_start, chunk_end): + row = rows[row_idx] with table_row(parent=table_tag): for col_idx, val in enumerate(row): try: @@ -394,9 +453,17 @@ def _on_data_ready(self, payload, seq: int, status_callback=None): }, ) - if status_callback: + if chunk_end < len(rows): + self._queue_data_rows_chunk( + table_tag, + rows, + chunk_end, + generation, + status_callback, + ) + elif status_callback: status_callback( - f"Explorer: Showing {len(result.result_rows)} rows from {self.current_table}" + f"Explorer: Showing {len(rows)} rows from {self.current_table}" ) def _on_data_error(self, e: Exception, seq: int, status_callback=None): diff --git a/tests/test_async_worker.py b/tests/test_async_worker.py new file mode 100644 index 0000000..d11e767 --- /dev/null +++ b/tests/test_async_worker.py @@ -0,0 +1,49 @@ +"""Tests for frame-budgeted main-thread callback processing.""" + +from unittest.mock import patch + +from async_worker import AsyncWorker + + +def test_process_pending_respects_callback_limit(): + worker = AsyncWorker() + calls = [] + for value in range(3): + worker.post_ui(lambda value=value: calls.append(value)) + + processed = worker.process_pending(time_budget_seconds=None, max_callbacks=2) + + assert processed == 2 + assert calls == [0, 1] + assert worker.process_pending(time_budget_seconds=None, max_callbacks=None) == 1 + assert calls == [0, 1, 2] + + +def test_process_pending_yields_after_time_budget(): + worker = AsyncWorker() + calls = [] + worker.post_ui(lambda: calls.append("first")) + worker.post_ui(lambda: calls.append("second")) + + with patch("async_worker.time.perf_counter", side_effect=[10.0, 10.005]): + processed = worker.process_pending( + time_budget_seconds=0.004, + max_callbacks=None, + ) + + assert processed == 1 + assert calls == ["first"] + assert worker.process_pending(time_budget_seconds=None, max_callbacks=None) == 1 + assert calls == ["first", "second"] + + +def test_background_result_is_delivered_through_ui_queue(): + worker = AsyncWorker() + results = [] + + thread = worker.run_async(task=lambda: 42, on_done=results.append) + thread.join(timeout=1) + + assert results == [] + assert worker.process_pending(time_budget_seconds=None, max_callbacks=None) == 1 + assert results == [42] diff --git a/tests/test_explorer_performance.py b/tests/test_explorer_performance.py index dfa9d79..82f5aeb 100644 --- a/tests/test_explorer_performance.py +++ b/tests/test_explorer_performance.py @@ -1,7 +1,7 @@ """Performance-focused tests for the data explorer.""" from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from data_explorer import DataExplorer @@ -27,3 +27,69 @@ def test_fetch_data_reuses_query_result_metadata(): ) db_manager.execute_query.assert_called_once_with("SELECT * FROM events") db_manager.get_table_columns.assert_not_called() + + +@patch("data_explorer.add_button", create=True) +@patch("data_explorer.table_row", create=True) +@patch("data_explorer.set_item_width", create=True) +@patch("data_explorer.configure_item", create=True) +@patch("data_explorer.add_table_column", create=True) +@patch("data_explorer.add_table", create=True) +@patch("data_explorer.delete_item", create=True) +@patch("data_explorer.does_item_exist", return_value=True, create=True) +def test_explorer_table_enables_clipping_and_queues_rows( + _exists, + _delete, + add_table, + _add_column, + _configure, + _set_width, + _table_row, + _add_button, +): + explorer = DataExplorer.__new__(DataExplorer) + explorer._refresh_seq = 1 + explorer._render_generation = 2 + explorer._active_table_tag = None + explorer.main_table_tag = "main_table" + explorer.table_theme = None + explorer.sort_column = None + explorer.sort_ascending = True + explorer.current_table = "events" + explorer.current_column_names = [] + explorer.async_worker = MagicMock() + result = SimpleNamespace( + column_names=("id", "name"), + result_rows=[(1, "one"), (2, "two")], + ) + + with ( + patch("data_explorer.mvTable_SizingFixedFit", 0, create=True), + patch.object(explorer, "_queue_data_rows_chunk") as queue_chunk, + ): + explorer._on_data_ready((result, {"id": "UInt64", "name": "String"}), 1) + + assert add_table.call_args.kwargs["clipper"] is True + assert queue_chunk.call_args.args[1] == result.result_rows + + +@patch("data_explorer.add_selectable", create=True) +@patch("data_explorer.table_row", create=True) +@patch("data_explorer.does_item_exist", return_value=True, create=True) +def test_explorer_rows_are_built_in_bounded_chunks(_exists, _table_row, add_selectable): + explorer = DataExplorer.__new__(DataExplorer) + explorer._render_generation = 4 + explorer._active_table_tag = "table" + explorer.async_worker = MagicMock() + explorer.current_table = "events" + explorer._last_status_callback = None + rows = [(i,) for i in range(5)] + + with ( + patch("data_explorer.RESULT_ROWS_PER_FRAME", 2), + patch.object(explorer, "_queue_data_rows_chunk") as queue_chunk, + ): + explorer._render_data_rows_chunk("table", rows, 0, 4) + + assert add_selectable.call_count == 2 + assert queue_chunk.call_args.args[2] == 2 diff --git a/tests/test_query_interface.py b/tests/test_query_interface.py index 3b87445..448cf14 100644 --- a/tests/test_query_interface.py +++ b/tests/test_query_interface.py @@ -21,8 +21,12 @@ # Mock config with real values config_mock = MagicMock() config_mock.DEFAULT_TAB_LABEL = "Query" +config_mock.MAX_CELL_LENGTH = 300 config_mock.MAX_QUERY_TABS = 10 +config_mock.MAX_ROWS_LIMIT = 1000 config_mock.QUERY_INPUT_HEIGHT = 150 +config_mock.RESULT_PAGE_SIZE = 100 +config_mock.RESULT_ROWS_PER_FRAME = 20 sys.modules["config"] = config_mock # Mock other dependencies @@ -34,8 +38,12 @@ # Force real values on the imported module qi_mod.DEFAULT_TAB_LABEL = "Query" +qi_mod.MAX_CELL_LENGTH = 300 qi_mod.MAX_QUERY_TABS = 10 +qi_mod.MAX_ROWS_LIMIT = 1000 qi_mod.QUERY_INPUT_HEIGHT = 150 +qi_mod.RESULT_PAGE_SIZE = 100 +qi_mod.RESULT_ROWS_PER_FRAME = 20 qi_mod.mvTable_SizingFixedFit = 0 # Alias for patch with create=True (star-import names don't exist on the module) @@ -47,7 +55,7 @@ def _patch(name, **kwargs): return patch(f"{_P}.{name}", create=True, **kwargs) -def _make_interface(theme_manager="DEFAULT"): +def _make_interface(theme_manager="DEFAULT", async_worker=None): """Create a TabbedQueryInterface with mocked dependencies. Always passes a MagicMock theme_manager to __init__ so that @@ -59,7 +67,12 @@ def _make_interface(theme_manager="DEFAULT"): pool = MagicMock() pool.is_configured = True db = MagicMock() - iface = TabbedQueryInterface(pool, db, theme_manager=MagicMock()) + iface = TabbedQueryInterface( + pool, + db, + theme_manager=MagicMock(), + async_worker=async_worker, + ) iface.theme_manager = theme_manager return iface @@ -576,6 +589,75 @@ def test_results_table_enables_row_clipping( self.assertTrue(add_table.call_args.kwargs["clipper"]) + @_patch("add_text") + @_patch("add_button") + @_patch("add_group") + @_patch("does_item_exist", return_value=False) + def test_result_page_caps_display_and_keeps_full_export( + self, _exists, _add_group, _add_button, add_text + ): + iface = _make_interface(theme_manager=None, async_worker=MagicMock()) + state = _make_state(tab_id=2) + state.last_query_results = [(i,) for i in range(1500)] + state.last_column_names = ("id",) + state.last_column_types = {"id": "UInt64"} + iface._tabs[2] = state + + with ( + patch.object(iface, "_hide_loading"), + patch.object(iface, "_setup_results_table"), + patch.object(iface, "_queue_result_rows_chunk") as queue_chunk, + ): + iface._render_result_page(2) + + self.assertEqual(len(state.last_query_results), 1500) + queued_rows = queue_chunk.call_args.args[2] + self.assertEqual(len(queued_rows), qi_mod.RESULT_PAGE_SIZE) + self.assertEqual(queue_chunk.call_args.args[-2:], (1500, 1000)) + warning = [call.args[0] for call in add_text.call_args_list if call.args] + self.assertTrue(any("capped at 1,000 of 1,500" in text for text in warning)) + + @_patch("add_input_text") + @_patch("add_selectable") + @_patch("table_row") + @_patch("does_item_exist", return_value=True) + def test_result_chunk_uses_lightweight_selectable_cells( + self, _exists, _table_row, add_selectable, add_input_text + ): + iface = _make_interface(async_worker=MagicMock()) + state = _make_state(tab_id=1) + state.current_table_tag = "result_table" + state.render_generation = 3 + iface._tabs[1] = state + rows = [(1, "a"), (2, "b")] + + iface._render_result_rows_chunk(1, 3, rows, 0, 0, 2, 2) + + self.assertEqual(add_selectable.call_count, 4) + add_input_text.assert_not_called() + iface.async_worker.post_ui.assert_not_called() + + @_patch("add_selectable") + @_patch("does_item_exist", return_value=True) + def test_stale_result_chunk_is_discarded(self, _exists, add_selectable): + iface = _make_interface(async_worker=MagicMock()) + state = _make_state(tab_id=1) + state.current_table_tag = "result_table" + state.render_generation = 4 + iface._tabs[1] = state + + iface._render_result_rows_chunk(1, 3, [(1,)], 0, 0, 1, 1) + + add_selectable.assert_not_called() + + def test_display_values_are_truncated_but_copy_value_can_remain_full(self): + value = "x" * (qi_mod.MAX_CELL_LENGTH + 20) + + formatted = TabbedQueryInterface._format_cell_value(value) + + self.assertEqual(len(formatted), qi_mod.MAX_CELL_LENGTH) + self.assertTrue(formatted.endswith("...")) + if __name__ == "__main__": unittest.main(verbosity=2)