From 9f98b8f265ed768611b3b3537d3320c42aee01d8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 13:13:23 +0300 Subject: [PATCH 1/5] ci(bench): the 200-table script is checked, a failed scenario run fails the job and says why (#1546) --- .github/workflows/benchmark.yml | 10 +++++ benchmark/app_perf_bench.dart | 40 ++++++++++++++++-- .../app_bench_report.cpython-314.pyc | Bin 0 -> 5216 bytes scripts/ci/app_bench_report.py | 15 ++++++- scripts/ci/test_app_bench_report.py | 10 +++++ 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 scripts/ci/__pycache__/app_bench_report.cpython-314.pyc diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 3c71f017..a14dc034 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -137,3 +137,13 @@ jobs: python3 scripts/ci/app_bench_report.py "$RUNNER_TEMP/app.log" > "$RUNNER_TEMP/app_report.txt" cat "$RUNNER_TEMP/app_report.txt" tail -n +2 "$RUNNER_TEMP/app_report.txt" >> "$GITHUB_STEP_SUMMARY" + + # `flutter run` exits 0 whatever the app does; an aborted scenario run + # prints `BENCH FAILED`, so the job turns red instead of reporting only the + # scenarios that ran. The job stays informational (continue-on-error). + - name: Fail when the run aborted + run: | + if grep -q 'BENCH FAILED' "$RUNNER_TEMP/app.log"; then + grep -A3 'BENCH FAILED' "$RUNNER_TEMP/app.log" | head -n 8 + exit 1 + fi diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 840de99f..8ed6fab9 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -81,13 +81,17 @@ Future main() async { runApp(const QueryaApp()); WidgetsBinding.instance.addPostFrameCallback((_) async { + var failed = false; try { await _runAll(); } catch (e, st) { - stdout.writeln('BENCH error: $e\n$st'); + // `flutter run` reports 0 whatever the app exits with, so the CI step + // looks for this line as well (scripts/ci/app_bench_report.py). + stdout.writeln('BENCH FAILED: $e\n$st'); + failed = true; } await dataDir.delete(recursive: true).catchError((_) => dataDir); - exit(0); + exit(failed ? 1 : 0); }); } @@ -257,10 +261,22 @@ Future _runAll() async { SqlEditorCommandBridge.instance.invokeNew(); await _wait(300); await _runInActiveTab(ddl.toString()); + // The script must have run: a failed or half-run script leaves the diagram + // of the demo's own tables, which looks like a slow ERD. + final ddlSession = ws.activeSession; + stdout.writeln('BENCH erd_ddl status=${ddlSession.statusLine} ' + 'error=${ddlSession.error}'); + if (ddlSession.error != null) { + throw StateError('the 200-table script failed: ${ddlSession.error}'); + } await _scenario('erd_open_200', () async { await _tap(_byKey('open_diagram_tab')); - await _waitFor(() => _find((e) => e.widget.key == const ValueKey('erd_table_erd_t199')) != null, - 'diagram of 200 tables'); + await _waitFor(() => ws.activeSession.isDiagram as bool, 'the Diagram tab', + onTimeout: () => _dumpErdState(ws)); + await _waitFor( + () => _find((e) => e.widget.key == const ValueKey('erd_table_erd_t199')) != null, + 'diagram of 200 tables', + onTimeout: () => _dumpErdState(ws)); await _wait(800); }); await _scenario('erd_hover', () => _hoverOver(_find((e) => e.widget is ErdView)!, 3000)); @@ -271,6 +287,22 @@ Future _runAll() async { }); } +/// What the screen shows when the diagram did not come up: the active tab and +/// how many table cards exist, so a failed run says which of the two it was. +void _dumpErdState(dynamic ws) { + var cards = 0; + void visit(Element e) { + final k = e.widget.key; + if (k is ValueKey && k.value.startsWith('erd_table_')) cards++; + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + final session = ws.activeSession; + stdout.writeln('BENCH erd_state activeTab="${session.title}" ' + 'isDiagram=${session.isDiagram} cards=$cards'); +} + /// The SQL workspace of the open connection. dynamic _workspace() => _findState((s) => s.runtimeType.toString() == 'GenericSqlWorkspaceState')!; diff --git a/scripts/ci/__pycache__/app_bench_report.cpython-314.pyc b/scripts/ci/__pycache__/app_bench_report.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbf27aa4839b3a6d209032ca02012309fdc552f4 GIT binary patch literal 5216 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 frames=.. build p50/p90/max=.. raster ..` lines and checks each scenario of LIMITS: build p90 against its limit (ms), and for multi_tab_run the number of query panes rebuilt by one run. The first output -line is `OVER=`, the number of scenarios over a limit or missing. -Informational: always exits 0. +line is `OVER=`, the number of scenarios over a limit or missing. A run that +aborted prints `BENCH FAILED: ` (`flutter run` exits 0 whatever the +app does), which the report names. Informational: always exits 0. """ import re import sys @@ -32,6 +33,13 @@ r"build p50/p90/max=([\d.]+)/([\d.]+)/([\d.]+)\s+" r"raster p50/p90/max=([\d.]+)/([\d.]+)/([\d.]+).*?stutters=(\d+)") _PANES = re.compile(r"BENCH multi_tab_run panes rebuilt=(\d+)") +_FAILED = re.compile(r"BENCH FAILED: (.*)$", re.M) + + +def failure(text): + """The reason the bench aborted, or None.""" + m = _FAILED.search(text) + return m[1].strip() if m else None def parse(text): @@ -75,6 +83,9 @@ def report(text): over += bad lines += ["", f"Query panes rebuilt by one run in one of five tabs: " f"**{panes}** (limit {MAX_PANES_REBUILT}){' ⚠️' if bad else ''}."] + failed = failure(text) + if failed: + lines += ["", f"⚠️ The run aborted: `{failed}`. Scenarios after it did not run."] lines += ["", "Times in ms, profile build under xvfb with software " "rendering. Informational, never blocks a merge."] return over, "\n".join(lines) diff --git a/scripts/ci/test_app_bench_report.py b/scripts/ci/test_app_bench_report.py index 5485b84a..93fb4b3a 100644 --- a/scripts/ci/test_app_bench_report.py +++ b/scripts/ci/test_app_bench_report.py @@ -64,6 +64,16 @@ def test_rebuilding_every_pane_is_flagged(self): self.assertEqual(over, 1) self.assertIn("**5**", body) + def test_an_aborted_run_is_named(self): + text = line("charts_5000") + "\nBENCH FAILED: Bad state: timeout waiting for the Diagram tab\nstack\n" + self.assertEqual(abr.failure(text), "Bad state: timeout waiting for the Diagram tab") + _, body = abr.report(text) + self.assertIn("The run aborted: `Bad state: timeout waiting for the Diagram tab`", body) + + def test_a_clean_run_has_no_failure(self): + self.assertIsNone(abr.failure(all_ok())) + self.assertNotIn("aborted", abr.report(all_ok())[1]) + def test_main_prints_the_count_first(self): with tempfile.TemporaryDirectory() as d: p = os.path.join(d, "app.log") From 688527ba6466b1cdfb89f386adcbc5cc1cd510a0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 13:26:59 +0300 Subject: [PATCH 2/5] ci(bench): open the diagram through the workspace; the toolbar button is outside the window with seven tabs (#1546) --- benchmark/app_perf_bench.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 8ed6fab9..484e1baa 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -270,7 +270,10 @@ Future _runAll() async { throw StateError('the 200-table script failed: ${ddlSession.error}'); } await _scenario('erd_open_200', () async { - await _tap(_byKey('open_diagram_tab')); + // Not a tap on the toolbar button: with seven query tabs open the tab bar + // is full and the button is pushed out of the 1280x720 window, so the tap + // landed on a tab and the diagram never opened (#1546). + ws.openDiagramTab(); await _waitFor(() => ws.activeSession.isDiagram as bool, 'the Diagram tab', onTimeout: () => _dumpErdState(ws)); await _waitFor( From c8be13c253f8c7c215189c2fb0dd0d58abbea08c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 14:13:48 +0300 Subject: [PATCH 3/5] perf(erd): a big diagram mounts its cards a chunk per frame and a rebuild reuses cards whose inputs did not change (#1546) --- lib/features/erd/erd_view.dart | 240 +++++++++++++++++++++++++------- test/features/erd/erd_test.dart | 58 ++++++++ 2 files changed, 246 insertions(+), 52 deletions(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 00fd4946..a57782c8 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -292,6 +292,9 @@ class _ErdViewState extends material.State _marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; + _cardCache = {}; + _mountedTables = + schema.tables.length > _progressiveFrom ? _mountChunk : 1 << 30; }); if (schema.truncated) { showAppToast( @@ -1397,6 +1400,126 @@ class _ErdViewState extends material.State ); } + /// Cards built so far for the open diagram: a big one mounts a chunk per + /// frame (see [_mountedTables]) instead of in one long frame. + static const _progressiveFrom = 60; + static const _mountChunk = 24; + int _mountedTables = 1 << 30; + bool _mountPending = false; + + /// Cards by table, with what they were built from. A rebuild of the diagram + /// (a pick, a drag, a toolbar change) reuses every card whose inputs did not + /// change, so Flutter skips its subtree instead of rebuilding 200 of them. + var _cardCache = {}; + + List _cards(ErdSchema schema, ErdLayout layout) { + final tables = schema.tables; + final shown = min(tables.length, _mountedTables); + final menuKey = Object.hash( + Object.hashAll(_marked), + Object.hashAll([ + for (final g in _groups) + Object.hash(g.id, g.name, Object.hashAll(g.tables)), + ]), + ); + final palette = context.semanticPalette; + final next = {}; + final cards = []; + for (var i = 0; i < shown; i++) { + final t = tables[i]; + final inputs = _CardInputs( + columns: t.columns, + comment: t.comment, + position: layout.positions[t.name]!, + width: layout.widthFor(t.name), + headerColor: _headerColor(t.name), + collapsed: _collapsed.contains(t.name), + marked: _marked.contains(t.name), + dragging: _dragging == t.name, + junctionOf: _junctions[t.name], + menuKey: menuKey, + palette: palette, + onOpenTable: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + ); + final cached = _cardCache[t.name]; + final card = cached != null && cached.$1 == inputs + ? cached.$2 + : _card(t, layout); + next[t.name] = (inputs, card); + cards.add(card); + } + _cardCache = next; + if (shown < tables.length) _mountMore(); + return cards; + } + + /// Mounts the next chunk of cards after this frame. + void _mountMore() { + if (_mountPending) return; + _mountPending = true; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + _mountPending = false; + if (!mounted) return; + setState(() => _mountedTables += _mountChunk); + }); + } + + material.Widget _card(ErdTable t, ErdLayout layout) { + return material.Positioned( + left: layout.positions[t.name]!.dx, + top: layout.positions[t.name]!.dy, + // Each card listens to its own focus, so a hover + // rebuilds only the cards it changes. + child: material.ValueListenableBuilder( + valueListenable: _cardState(t.name), + builder: (context, focus, _) => material.Opacity( + // Unrelated cards fade while a table is picked. + opacity: focus.faded ? 0.35 : 1, + child: ContextMenu( + items: ErdTableMenu.items( + table: t.name, + onOpenData: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + collapsed: _collapsed.contains(t.name), + onToggleCollapsed: () => + _toggleCollapsed(t.name), + onHide: () => _hide(t.name), + groupItems: _groupMenu(t.name), + slotColor: _slotColor, + onHeaderColor: (slot) => + _setHeaderColor(t.name, slot), + ), + child: ErdTableCard( + table: t, + width: layout.widthFor(t.name), + headerColor: _headerColor(t.name), + highlighted: focus.highlighted, + focusColumn: focus.column, + junctionOf: _junctions[t.name], + dragging: _dragging == t.name, + onOpen: widget.onOpenTable == null + ? null + : () => widget.onOpenTable!(t.name), + marked: _marked.contains(t.name), + onSelect: () => _tapCard(t.name), + onHover: (inside) => _hovered.value = inside + ? t.name + : (_hovered.value == t.name + ? null + : _hovered.value), + onDragStart: () => _dragStart(t.name), + onDragMove: (d) => _dragMove(t.name, d), + onDragEnd: _dragEnd, + ), + ), + ), + ), + ); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -1481,58 +1604,7 @@ class _ErdViewState extends material.State ), ), ), - for (final t in schema.tables) - material.Positioned( - left: layout.positions[t.name]!.dx, - top: layout.positions[t.name]!.dy, - // Each card listens to its own focus, so a hover - // rebuilds only the cards it changes. - child: material.ValueListenableBuilder( - valueListenable: _cardState(t.name), - builder: (context, focus, _) => material.Opacity( - // Unrelated cards fade while a table is picked. - opacity: focus.faded ? 0.35 : 1, - child: ContextMenu( - items: ErdTableMenu.items( - table: t.name, - onOpenData: widget.onOpenTable, - onOpenInSql: widget.onOpenInSql, - onShowRelations: widget.onShowRelations, - collapsed: _collapsed.contains(t.name), - onToggleCollapsed: () => - _toggleCollapsed(t.name), - onHide: () => _hide(t.name), - groupItems: _groupMenu(t.name), - slotColor: _slotColor, - onHeaderColor: (slot) => - _setHeaderColor(t.name, slot), - ), - child: ErdTableCard( - table: t, - width: layout.widthFor(t.name), - headerColor: _headerColor(t.name), - highlighted: focus.highlighted, - focusColumn: focus.column, - junctionOf: _junctions[t.name], - dragging: _dragging == t.name, - onOpen: widget.onOpenTable == null - ? null - : () => widget.onOpenTable!(t.name), - marked: _marked.contains(t.name), - onSelect: () => _tapCard(t.name), - onHover: (inside) => _hovered.value = inside - ? t.name - : (_hovered.value == t.name - ? null - : _hovered.value), - onDragStart: () => _dragStart(t.name), - onDragMove: (d) => _dragMove(t.name, d), - onDragEnd: _dragEnd, - ), - ), - ), - ), - ), + ..._cards(schema, layout), // Sticky notes above the cards (#1283). for (final n in _notes) _noteWidget(n), ], @@ -1703,3 +1775,67 @@ class _ErdViewState extends material.State ); } } + +/// What a table card is built from outside its own focus: when all of it is +/// equal to the last build's, the card is reused as it is. +@immutable +class _CardInputs { + const _CardInputs({ + required this.columns, + required this.comment, + required this.position, + required this.width, + required this.headerColor, + required this.collapsed, + required this.marked, + required this.dragging, + required this.junctionOf, + required this.menuKey, + required this.palette, + required this.onOpenTable, + required this.onOpenInSql, + required this.onShowRelations, + }); + + final List columns; + final String? comment; + final material.Offset position; + final double width; + final material.Color headerColor; + final bool collapsed; + final bool marked; + final bool dragging; + final String? junctionOf; + + /// Hash of what the card's menu lists: the marked tables and the groups. + final int menuKey; + final Object palette; + final Object? onOpenTable; + final Object? onOpenInSql; + final Object? onShowRelations; + + @override + bool operator ==(Object other) { + if (other is! _CardInputs) return false; + if (columns.length != other.columns.length) return false; + for (var i = 0; i < columns.length; i++) { + if (!identical(columns[i], other.columns[i])) return false; + } + return comment == other.comment && + position == other.position && + width == other.width && + headerColor == other.headerColor && + collapsed == other.collapsed && + marked == other.marked && + dragging == other.dragging && + junctionOf == other.junctionOf && + menuKey == other.menuKey && + identical(palette, other.palette) && + onOpenTable == other.onOpenTable && + onOpenInSql == other.onOpenInSql && + onShowRelations == other.onShowRelations; + } + + @override + int get hashCode => Object.hash(columns.length, position, width, menuKey); +} diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 8963656d..bb97e152 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -1004,6 +1004,64 @@ void main() { expect(erdCardBuilds, lessThanOrEqualTo(3)); }); + testWidgets('a diagram of many tables mounts its cards over several frames', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final big = FakeSqlExecutionDelegate(onExecute: (sql) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (var i = 0; i < 100; i++) ['t$i', 'id', 'INTEGER', '1'], + ]); + } + return const SqlExecutionResult(); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: big, dialect: SqlDialect.sqlite)), + )); + int cards() => find + .byWidgetPredicate((w) => + w.key is material.ValueKey && + (w.key! as material.ValueKey).value.startsWith('erd_table_t')) + .evaluate() + .length; + await t.pump(); + await t.pump(); + expect(cards(), inInclusiveRange(1, 99), + reason: 'the first frame does not carry every card'); + for (var i = 0; i < 10; i++) { + await t.pump(); + } + expect(cards(), 100); + }); + + testWidgets('picking a table rebuilds only the cards whose look changes', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final wide = FakeSqlExecutionDelegate(onExecute: (sql) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (var i = 0; i < 20; i++) ['t$i', 'id', 'INTEGER', '1'], + ]); + } + return const SqlExecutionResult(); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: wide, dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + await t.pump(); + + erdCardBuilds = 0; + await t.tap(find.byKey(const material.ValueKey('erd_table_t5'))); + await t.pump(); + expect(erdCardBuilds, greaterThan(0)); + expect(erdCardBuilds, lessThan(20), + reason: 'the diagram rebuilt, the cards that did not change did not'); + }); + testWidgets('Copy Mermaid puts the diagram on the clipboard', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); String? copied; From fc07bf7e66cc3fb7d67255568cb7a36bd12bbcb3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 14:23:22 +0300 Subject: [PATCH 4/5] test(erd): a rebuild without a look change reuses every card (search panel), picking fades the others so it cannot show that --- test/features/erd/erd_test.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index bb97e152..ed4496be 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -13,6 +13,7 @@ import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_router.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_search_panel.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; @@ -1035,7 +1036,7 @@ void main() { expect(cards(), 100); }); - testWidgets('picking a table rebuilds only the cards whose look changes', + testWidgets('opening the search rebuilds the diagram but not its cards', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); final wide = FakeSqlExecutionDelegate(onExecute: (sql) { @@ -1054,12 +1055,15 @@ void main() { await t.pump(); await t.pump(); + // Ctrl+F sets state on the diagram; no card looks different afterwards. erdCardBuilds = 0; - await t.tap(find.byKey(const material.ValueKey('erd_table_t5'))); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyF); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await t.pump(); - expect(erdCardBuilds, greaterThan(0)); - expect(erdCardBuilds, lessThan(20), - reason: 'the diagram rebuilt, the cards that did not change did not'); + expect(find.byType(ErdSearchPanel), findsOneWidget); + expect(erdCardBuilds, 0, + reason: 'the diagram rebuilt, the 20 cards were reused'); }); testWidgets('Copy Mermaid puts the diagram on the clipboard', (t) async { From 6d36837ee876a008cdcc14205e0dd19de121830b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 14:40:33 +0300 Subject: [PATCH 5/5] fix(erd): the card cache compared a palette built anew on each read, so it never hit; compare its slot colours (#1546) --- lib/features/erd/erd_view.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index a57782c8..5feeb843 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1422,7 +1422,10 @@ class _ErdViewState extends material.State Object.hash(g.id, g.name, Object.hashAll(g.tables)), ]), ); - final palette = context.semanticPalette; + // The palette is built anew on each read; its slot colours are what the + // cards and the menu use. + final p = context.semanticPalette; + final palette = (p.type1, p.type2, p.type3, p.type4, p.type5); final next = {}; final cards = []; for (var i = 0; i < shown; i++) { @@ -1830,7 +1833,7 @@ class _CardInputs { dragging == other.dragging && junctionOf == other.junctionOf && menuKey == other.menuKey && - identical(palette, other.palette) && + palette == other.palette && onOpenTable == other.onOpenTable && onOpenInSql == other.onOpenInSql && onShowRelations == other.onShowRelations;