From 96b26e29f07bbd3ecce8e76ea29b96f1e9647b87 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Apr 2026 15:47:14 +0200 Subject: [PATCH] =?UTF-8?q?add=20app=20test=20suite=20=E2=80=94=20unit,=20?= =?UTF-8?q?widget,=20golden,=20a11y=20(168=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four layers under app/test/: * kernel/, extension/ — unit tests for every kernel service and the extension contract. i18n fallback chain gets the full matrix (exact/lang/default/placeholder, namespace isolation, unknown namespace, interpolation). Theme resolver and extension-manager topo sort covered. * widgets/, builtin/ — widget tests for every primitive and each Tier 0 built-in. Assertions reach into the Semantics node so a missing label fails structurally, not visually. * goldens/ — Alchemist + Ahem font for cross-platform pixel stability; primitives only (button, tab bar, icon set). No goldens for compositions — they'd churn through every tier. * a11y/ — contract-level gate: semantic coverage walks the built-in catalogue, contrast walks every token pair in every bundled theme against WCAG-AA thresholds (catches real regressions: the first run caught `tab.inactive_text` at 2.81 in summer-night), i18n coverage asserts every referenced key exists in its catalog (exists-in-map, not "key == value" — too ambiguous for keys that happen to equal their translation). Helpers under helpers/: `KernelFixture.create()` boots a KernelServices with in-memory stores + a FakeDaemonClient (no socket, drivable connected-state), `widget_harness` wraps a widget in the minimum tree that resolves theme + i18n. Co-Authored-By: Claude --- CHANGELOG.md | 6 + app/test/a11y/contrast_test.dart | 37 +++ app/test/a11y/i18n_coverage_test.dart | 65 +++++ app/test/a11y/keyboard_traversal_test.dart | 48 ++++ app/test/a11y/semantic_coverage_test.dart | 66 +++++ .../builtin/default_layout/widget_test.dart | 52 ++++ app/test/builtin/ipc_status/widget_test.dart | 61 +++++ .../builtin/theme_picker/widget_test.dart | 117 +++++++++ app/test/builtin/welcome/widget_test.dart | 82 +++++++ app/test/extension/src/contribution_test.dart | 62 +++++ app/test/extension/src/host_test.dart | 58 +++++ app/test/extension/src/manifest_test.dart | 69 ++++++ .../goldens/clide_button_goldens_test.dart | 85 +++++++ app/test/goldens/clide_icon_goldens_test.dart | 44 ++++ .../goldens/clide_tab_bar_goldens_test.dart | 60 +++++ app/test/goldens/goldens/ci/clide_button.png | Bin 0 -> 2353 bytes app/test/goldens/goldens/ci/clide_icon.png | Bin 0 -> 5013 bytes app/test/goldens/goldens/ci/clide_tab_bar.png | Bin 0 -> 810 bytes .../goldens/goldens/linux/clide_button.png | Bin 0 -> 15778 bytes app/test/goldens/goldens/linux/clide_icon.png | Bin 0 -> 11777 bytes .../goldens/goldens/linux/clide_tab_bar.png | Bin 0 -> 7258 bytes app/test/helpers/fake_ipc.dart | 57 +++++ app/test/helpers/golden_harness.dart | 22 ++ app/test/helpers/kernel_fixture.dart | 83 +++++++ app/test/helpers/widget_harness.dart | 22 ++ .../kernel/src/commands/keybindings_test.dart | 45 ++++ .../kernel/src/commands/palette_test.dart | 61 +++++ .../kernel/src/commands/registry_test.dart | 51 ++++ app/test/kernel/src/events/bus_test.dart | 69 ++++++ .../kernel/src/extensions_manager_test.dart | 192 +++++++++++++++ app/test/kernel/src/facade_test.dart | 63 +++++ app/test/kernel/src/i18n/i18n_test.dart | 228 ++++++++++++++++++ app/test/kernel/src/log_test.dart | 71 ++++++ .../kernel/src/panels/arrangement_test.dart | 50 ++++ app/test/kernel/src/panels/registry_test.dart | 76 ++++++ app/test/kernel/src/settings_test.dart | 94 ++++++++ .../kernel/src/theme/controller_test.dart | 89 +++++++ app/test/kernel/src/theme/loader_test.dart | 80 ++++++ app/test/kernel/src/theme/resolver_test.dart | 128 ++++++++++ app/test/widgets/src/clide_button_test.dart | 94 ++++++++ app/test/widgets/src/clide_divider_test.dart | 31 +++ app/test/widgets/src/clide_icon_test.dart | 33 +++ app/test/widgets/src/clide_surface_test.dart | 51 ++++ app/test/widgets/src/clide_tab_bar_test.dart | 88 +++++++ app/test/widgets/src/clide_text_test.dart | 40 +++ 45 files changed, 2730 insertions(+) create mode 100644 app/test/a11y/contrast_test.dart create mode 100644 app/test/a11y/i18n_coverage_test.dart create mode 100644 app/test/a11y/keyboard_traversal_test.dart create mode 100644 app/test/a11y/semantic_coverage_test.dart create mode 100644 app/test/builtin/default_layout/widget_test.dart create mode 100644 app/test/builtin/ipc_status/widget_test.dart create mode 100644 app/test/builtin/theme_picker/widget_test.dart create mode 100644 app/test/builtin/welcome/widget_test.dart create mode 100644 app/test/extension/src/contribution_test.dart create mode 100644 app/test/extension/src/host_test.dart create mode 100644 app/test/extension/src/manifest_test.dart create mode 100644 app/test/goldens/clide_button_goldens_test.dart create mode 100644 app/test/goldens/clide_icon_goldens_test.dart create mode 100644 app/test/goldens/clide_tab_bar_goldens_test.dart create mode 100644 app/test/goldens/goldens/ci/clide_button.png create mode 100644 app/test/goldens/goldens/ci/clide_icon.png create mode 100644 app/test/goldens/goldens/ci/clide_tab_bar.png create mode 100644 app/test/goldens/goldens/linux/clide_button.png create mode 100644 app/test/goldens/goldens/linux/clide_icon.png create mode 100644 app/test/goldens/goldens/linux/clide_tab_bar.png create mode 100644 app/test/helpers/fake_ipc.dart create mode 100644 app/test/helpers/golden_harness.dart create mode 100644 app/test/helpers/kernel_fixture.dart create mode 100644 app/test/helpers/widget_harness.dart create mode 100644 app/test/kernel/src/commands/keybindings_test.dart create mode 100644 app/test/kernel/src/commands/palette_test.dart create mode 100644 app/test/kernel/src/commands/registry_test.dart create mode 100644 app/test/kernel/src/events/bus_test.dart create mode 100644 app/test/kernel/src/extensions_manager_test.dart create mode 100644 app/test/kernel/src/facade_test.dart create mode 100644 app/test/kernel/src/i18n/i18n_test.dart create mode 100644 app/test/kernel/src/log_test.dart create mode 100644 app/test/kernel/src/panels/arrangement_test.dart create mode 100644 app/test/kernel/src/panels/registry_test.dart create mode 100644 app/test/kernel/src/settings_test.dart create mode 100644 app/test/kernel/src/theme/controller_test.dart create mode 100644 app/test/kernel/src/theme/loader_test.dart create mode 100644 app/test/kernel/src/theme/resolver_test.dart create mode 100644 app/test/widgets/src/clide_button_test.dart create mode 100644 app/test/widgets/src/clide_divider_test.dart create mode 100644 app/test/widgets/src/clide_icon_test.dart create mode 100644 app/test/widgets/src/clide_surface_test.dart create mode 100644 app/test/widgets/src/clide_tab_bar_test.dart create mode 100644 app/test/widgets/src/clide_text_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 18274712..ac23405b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- App-level test suite under `app/test/` — 168 tests across four layers: + - **Unit** (`kernel/`, `extension/`) — events bus, settings (scope + YAML round-trip), log, i18n fallback chain matrix, theme resolver + loader + controller, panel registry + arrangement, command registry + keybinding parser + palette filter, extension-manager dep-order / cycle detection / enable-disable, manifest loader, extension scanner. + - **Widget** (`widgets/`, `builtin/`) — every primitive's Semantics presence + token consumption + hover/press states; each Tier 0 built-in's contributions, view, and locale-switch re-render. + - **Golden** (`goldens/`) — widget primitives only, Alchemist + Ahem font; PNG fixtures checked in under `_files/ci/` and `_files/linux/`. + - **A11y** (`a11y/`) — `semantic_coverage_test.dart` (contract-level check that every built-in carries title + version + label-ready contributions), `contrast_test.dart` (WCAG-AA ratio gate on every bundled theme's canonical token pairs), `i18n_coverage_test.dart` (asserts every Tier-0-referenced key is present in its `en_US` catalog), `keyboard_traversal_test.dart` (focusability smoke). +- Test helpers under `app/test/helpers/` — `KernelFixture` (boots a KernelServices with in-memory defaults + a fake daemon for widget-level tests), `FakeDaemonClient` (subclasses the real client, no socket, drivable connected-state), `golden_harness` (Alchemist config with Ahem font for cross-platform pixel stability), `widget_harness` (wraps a widget in Directionality + ClideKernel + ClideTheme + MediaQuery). - Flutter desktop app scaffold under `app/` with a bare `WidgetsApp` root (no Material, no Cupertino) and the Tier 0 three-column layout. - **Kernel** (`app/lib/kernel/`) — 18 services consumed by every extension: `settings` (scope-resolved get/set across `app.*`/`project.*`/`ext.*`), `project`, `extensions`, `theme`, `panels` (slot registry + arrangement), `events`, `ipc`, `commands` (+ palette + keybinding resolver), `clipboard`, `files`, `notify`, `dialog` (single-at-a-time modal router), `tray`, `secrets`, `os`, `net`, `focus`, and `log`. Unified in a `ClideKernel` `InheritedWidget`. - **i18n** (`app/lib/kernel/src/i18n/`) — text-driven lookup ported from [fframe](https://github.com/postmeridiem/fframe)'s `L10n`: namespaced JSON catalogs, `string()` / `interpolated()` calls with caller-supplied placeholders, and a proper locale fallback chain (exact → language → default-country → default-language → placeholder). Improves on fframe's design by adding the chain, which fframe lacks. diff --git a/app/test/a11y/contrast_test.dart b/app/test/a11y/contrast_test.dart new file mode 100644 index 00000000..27215f66 --- /dev/null +++ b/app/test/a11y/contrast_test.dart @@ -0,0 +1,37 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; + +/// WCAG-AA contrast gate. Every bundled theme's token pairs (listed in +/// [canonicalPairs]) must clear 4.5:1 for normal text / 3:1 for large +/// text. Failing pairs are printed with their computed ratio so a +/// theme-token regression shows exactly which pair broke. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('contrast — all bundled themes meet WCAG AA', () { + const bundledPaths = [ + 'lib/kernel/src/theme/themes/summer-night.yaml', + ]; + + for (final path in bundledPaths) { + test('theme: $path', () async { + final def = await const ThemeLoader().fromAsset(rootBundle, path); + const resolver = ThemeResolver(); + final tokens = resolver.resolve( + palette: def.palette, + semanticOverride: def.semanticOverride, + surfaceOverride: def.surfaceOverride, + extensionOverride: def.extensionOverride, + ); + final failures = failingPairs(tokens); + if (failures.isNotEmpty) { + fail( + 'Contrast failures in ${def.name}:\n' + '${failures.map((f) => ' - $f').join('\n')}', + ); + } + }); + } + }); +} diff --git a/app/test/a11y/i18n_coverage_test.dart b/app/test/a11y/i18n_coverage_test.dart new file mode 100644 index 00000000..90ed88fc --- /dev/null +++ b/app/test/a11y/i18n_coverage_test.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; + +/// Asserts every bundled i18n catalog is well-formed and every key the +/// Tier-0 built-ins ask for actually resolves. +/// +/// The second check is important: in a text-driven i18n system missing +/// keys show the placeholder, so a runtime lookup test wouldn't "fail" +/// on a typo — we have to assert the keys exist up front. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// (namespace, key) pairs referenced by Tier-0 built-ins. Extend when + /// new keys land. + const referenced = >{ + 'builtin.welcome': [ + 'title', + 'subtitle', + 'open-project', + 'open-project.hint', + 'tab.title', + ], + 'builtin.ipc-status': [ + 'connected', + 'connected.hint', + 'disconnected', + 'disconnected.hint', + ], + 'builtin.theme-picker': [ + 'modal.title', + 'modal.cancel', + 'modal.cancel.hint', + 'row.select.hint', + ], + 'builtin.default-layout': [ + 'command.reset', + 'preset.classic', + ], + }; + + group('i18n coverage (Tier 0)', () { + for (final entry in referenced.entries) { + final ns = entry.key; + test('$ns catalog contains every referenced key', () async { + final loader = AssetCatalogLoader(bundle: rootBundle); + final catalog = await loader.load(ns, const Locale('en', 'US')); + expect( + catalog, + isNotEmpty, + reason: 'catalog for "$ns" failed to load (asset path wrong?)', + ); + for (final key in entry.value) { + expect( + catalog.containsKey(key), + isTrue, + reason: 'namespace "$ns" catalog is missing key "$key"', + ); + } + }); + } + }); +} diff --git a/app/test/a11y/keyboard_traversal_test.dart b/app/test/a11y/keyboard_traversal_test.dart new file mode 100644 index 00000000..60ec6a3f --- /dev/null +++ b/app/test/a11y/keyboard_traversal_test.dart @@ -0,0 +1,48 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +/// Tier-0 smoke — each interactive primitive can *hold* focus when a +/// [Focus] wraps it, and Semantics reports the right tap action. Full +/// Tab-traversal order lives in `integration_test/app_starts_test.dart` +/// where the real WidgetsApp + DefaultFocusTraversal are present. +void main() { + group('keyboard focusability', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('ClideButton can be programmatically focused', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + await tester.pumpWidget( + harness( + f, + Focus( + focusNode: node, + child: ClideButton(label: 'Save', onPressed: () {}), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, isTrue); + }); + + testWidgets('interactive widgets expose tap actions to a11y', + (tester) async { + await tester.pumpWidget( + harness(f, ClideButton(label: 'Save', onPressed: () {})), + ); + final handle = tester.ensureSemantics(); + final data = + tester.getSemantics(find.byType(ClideButton)).getSemanticsData(); + expect(data.hasAction(SemanticsAction.tap), isTrue); + handle.dispose(); + }); + }); +} diff --git a/app/test/a11y/semantic_coverage_test.dart b/app/test/a11y/semantic_coverage_test.dart new file mode 100644 index 00000000..8ac44a57 --- /dev/null +++ b/app/test/a11y/semantic_coverage_test.dart @@ -0,0 +1,66 @@ +import 'package:clide_app/builtin/default_layout/default_layout.dart'; +import 'package:clide_app/builtin/ipc_status/ipc_status.dart'; +import 'package:clide_app/builtin/theme_picker/theme_picker.dart'; +import 'package:clide_app/builtin/welcome/welcome.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Cross-extension contract check: every built-in extension declares a +/// non-empty title + version (so screen readers and the extensions-ui +/// surface can announce them) and every interactive contribution +/// carries enough data to build a Semantics node. +/// +/// Per-widget Semantics-label checks live in each built-in's own +/// widget_test.dart (`test/builtin/**/widget_test.dart`) — they run +/// against a real pumped tree and assert the exact `label` + `hint` +/// reach the user. This file is the "nothing is missing from the +/// catalog" gate. +void main() { + group('Tier-0 built-in extensions — contract-level coverage', () { + final extensions = [ + DefaultLayoutExtension(), + WelcomeExtension(), + IpcStatusExtension(), + ThemePickerExtension(), + ]; + + for (final ext in extensions) { + group(ext.id, () { + test('has non-empty title + version', () { + expect(ext.title, isNotEmpty); + expect(ext.version, isNotEmpty); + }); + + test('tab contributions carry title + i18n key + namespace', () { + final tabs = ext.contributions.whereType().toList(); + for (final t in tabs) { + expect(t.title, isNotEmpty, + reason: '${ext.id} tab ${t.id} missing English title'); + if (t.titleKey != null) { + expect(t.i18nNamespace, isNotNull, + reason: + '${ext.id} tab ${t.id} has titleKey but no namespace'); + } + } + }); + + test('command contributions carry stable ids', () { + final cmds = + ext.contributions.whereType().toList(); + for (final c in cmds) { + expect(c.command, isNotEmpty); + expect(c.id, isNotEmpty); + } + }); + }); + } + + test('every registered tab hits a real slot', () { + for (final ext in extensions) { + for (final t in ext.contributions.whereType()) { + expect(t.slot.value, isNotEmpty); + } + } + }); + }); +} diff --git a/app/test/builtin/default_layout/widget_test.dart b/app/test/builtin/default_layout/widget_test.dart new file mode 100644 index 00000000..31a70597 --- /dev/null +++ b/app/test/builtin/default_layout/widget_test.dart @@ -0,0 +1,52 @@ +import 'package:clide_app/builtin/default_layout/default_layout.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + group('DefaultLayoutExtension', () { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + test('activates and applies the classic preset', () async { + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activateAll(); + expect( + f.services.arrangement.positionOf(Slots.sidebar), SlotPosition.left); + expect(f.services.arrangement.sizeOf(Slots.sidebar), 240); + expect(f.services.arrangement.positionOf(Slots.workspace), + SlotPosition.center); + expect(f.services.arrangement.positionOf(Slots.statusbar), + SlotPosition.bottom); + }); + + test('contributes a layout.reset command', () async { + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activateAll(); + final reset = f.services.commands.get('layout.reset'); + expect(reset, isNotNull); + }); + + test('layout.reset re-applies the preset', () async { + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activateAll(); + f.services.arrangement.setSize(Slots.sidebar, 300); + expect(f.services.arrangement.sizeOf(Slots.sidebar), 300); + final resp = await f.services.commands.execute('layout.reset'); + expect(resp.ok, true); + expect(f.services.arrangement.sizeOf(Slots.sidebar), 240); + }); + + test('declares a layout preset contribution', () { + final ext = DefaultLayoutExtension(); + expect( + ext.contributions.whereType(), + hasLength(1), + ); + }); + }); +} diff --git a/app/test/builtin/ipc_status/widget_test.dart b/app/test/builtin/ipc_status/widget_test.dart new file mode 100644 index 00000000..571d1350 --- /dev/null +++ b/app/test/builtin/ipc_status/widget_test.dart @@ -0,0 +1,61 @@ +import 'dart:ui'; + +import 'package:clide_app/builtin/ipc_status/ipc_status.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('IpcStatusExtension', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create( + i18nCatalogs: { + 'builtin.ipc-status': { + const Locale('en', 'US'): const { + 'connected': {'translation': 'connected'}, + 'connected.hint': {'translation': 'daemon reachable'}, + 'disconnected': {'translation': 'disconnected'}, + 'disconnected.hint': {'translation': 'daemon down'}, + }, + }, + }, + ); + }); + + tearDown(() async => f.dispose()); + + test('contributes a statusbar item', () async { + f.services.extensions.register(IpcStatusExtension()); + await f.services.extensions.activateAll(); + final items = f.services.panels + .contributionsFor(Slots.statusbar) + .whereType() + .toList(); + expect(items, hasLength(1)); + expect(items.first.priority, 100); + }); + + testWidgets('renders "disconnected" label until connected', (tester) async { + await tester.pumpWidget( + harness(f, IpcStatusItem(ipc: f.services.ipc)), + ); + expect(find.text('disconnected'), findsOneWidget); + }); + + testWidgets('flips to "connected" when the client reports connected', + (tester) async { + await tester.pumpWidget( + harness(f, IpcStatusItem(ipc: f.services.ipc)), + ); + f.ipc.setConnected(true); + await tester.pumpAndSettle(); + expect(find.text('connected'), findsOneWidget); + }); + }); +} diff --git a/app/test/builtin/theme_picker/widget_test.dart b/app/test/builtin/theme_picker/widget_test.dart new file mode 100644 index 00000000..950d1284 --- /dev/null +++ b/app/test/builtin/theme_picker/widget_test.dart @@ -0,0 +1,117 @@ +import 'dart:ui'; + +import 'package:clide_app/builtin/theme_picker/theme_picker.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +ThemeDefinition _def(String name) => ThemeDefinition( + name: name, + displayName: name, + dark: true, + palette: Palette(const { + 'primary': Color(0xFF00A3D2), + 'accent': Color(0xFFFA5F8B), + 'background': Color(0xFF21262F), + 'surface': Color(0xFF393E48), + 'panel': Color(0xFF292E38), + 'foreground': Color(0xFFE2E8F5), + 'success': Color(0xFF00AB9A), + 'warning': Color(0xFFD08447), + 'error': Color(0xFFF06C6F), + }), + ); + +void main() { + group('ThemePickerExtension', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create( + bundledThemes: [_def('summer-night'), _def('forest')], + i18nCatalogs: { + 'builtin.theme-picker': { + const Locale('en', 'US'): const { + 'modal.title': {'translation': 'Select theme'}, + 'modal.cancel': {'translation': 'Cancel'}, + 'modal.cancel.hint': {'translation': 'Dismiss'}, + 'row.select.hint': {'translation': 'Activate this theme'}, + }, + }, + }, + ); + }); + + tearDown(() async => f.dispose()); + + test('contributes a theme.pick command', () async { + f.services.extensions.register(ThemePickerExtension()); + await f.services.extensions.activateAll(); + expect(f.services.commands.get('theme.pick'), isNotNull); + }); + + test('default binding ctrl+k is registered', () async { + f.services.extensions.register(ThemePickerExtension()); + await f.services.extensions.activateAll(); + expect( + f.services.keybindings.commandFor(Keybinding.parse('ctrl+k')), + 'theme.pick', + ); + }); + + testWidgets('modal lists every bundled theme', (tester) async { + await tester.pumpWidget( + harness( + f, + ThemePickerView( + controller: f.services.theme, + onDismiss: ([_]) {}, + ), + ), + ); + // Each row renders both displayName and name; displayName==name in + // test fixtures so the label appears twice per row. + expect(find.text('summer-night'), findsNWidgets(2)); + expect(find.text('forest'), findsNWidgets(2)); + expect(find.text('Select theme'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('tapping a row calls controller.select + onDismiss', + (tester) async { + String? dismissed; + await tester.pumpWidget( + harness( + f, + ThemePickerView( + controller: f.services.theme, + onDismiss: ([v]) => dismissed = v, + ), + ), + ); + await tester.tap(find.bySemanticsLabel('forest')); + await tester.pumpAndSettle(); + expect(f.services.theme.currentName, 'forest'); + expect(dismissed, 'forest'); + }); + + testWidgets('Cancel button dismisses without selecting', (tester) async { + String? dismissed = 'not-called'; + await tester.pumpWidget( + harness( + f, + ThemePickerView( + controller: f.services.theme, + onDismiss: ([v]) => dismissed = v, + ), + ), + ); + await tester.tap(find.bySemanticsLabel('Cancel')); + await tester.pumpAndSettle(); + expect(dismissed, isNull); + }); + }); +} diff --git a/app/test/builtin/welcome/widget_test.dart b/app/test/builtin/welcome/widget_test.dart new file mode 100644 index 00000000..fc8b92bc --- /dev/null +++ b/app/test/builtin/welcome/widget_test.dart @@ -0,0 +1,82 @@ +import 'dart:ui'; + +import 'package:clide_app/builtin/welcome/welcome.dart'; +import 'package:clide_app/builtin/welcome/src/welcome_view.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('WelcomeExtension', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create( + i18nCatalogs: { + 'builtin.welcome': { + const Locale('en', 'US'): const { + 'title': {'translation': 'clide'}, + 'subtitle': { + 'translation': 'Flutter desktop IDE for Claude Code' + }, + 'open-project': {'translation': 'Open project'}, + 'open-project.hint': {'translation': 'Pick a git repository'}, + 'tab.title': {'translation': 'Welcome'}, + }, + }, + }, + ); + }); + + tearDown(() async => f.dispose()); + + test('contributes a workspace tab with an i18n title key', () { + final ext = WelcomeExtension(); + final tabs = ext.contributions.whereType().toList(); + expect(tabs, hasLength(1)); + expect(tabs.first.slot, Slots.workspace); + expect(tabs.first.titleKey, 'tab.title'); + expect(tabs.first.i18nNamespace, ext.id); + }); + + testWidgets('WelcomeView renders title + subtitle + Open-project button', + (tester) async { + await tester.pumpWidget(harness(f, const WelcomeView())); + expect(find.text('clide'), findsOneWidget); + expect( + find.text('Flutter desktop IDE for Claude Code'), + findsOneWidget, + ); + expect(find.text('Open project'), findsOneWidget); + }); + + testWidgets('Open-project button is a Semantics button w/ hint', + (tester) async { + await tester.pumpWidget(harness(f, const WelcomeView())); + final semantics = + tester.getSemantics(find.bySemanticsLabel('Open project')); + expect(semantics.label, 'Open project'); + expect(semantics.hint, 'Pick a git repository'); + }); + + testWidgets('locale switch refreshes the visible text', (tester) async { + await tester.pumpWidget(harness(f, const WelcomeView())); + f.services.i18n + .registerCatalog('builtin.welcome', const Locale('nl'), const { + 'title': {'translation': 'clide-nl'}, + 'subtitle': {'translation': 'Flutter IDE voor Claude Code'}, + 'open-project': {'translation': 'Project openen'}, + 'open-project.hint': {'translation': 'Kies een git repo'}, + 'tab.title': {'translation': 'Welkom'}, + }); + await f.services.i18n.setLocale(const Locale('nl')); + await tester.pumpAndSettle(); + expect(find.text('Project openen'), findsOneWidget); + expect(find.text('clide-nl'), findsOneWidget); + }); + }); +} diff --git a/app/test/extension/src/contribution_test.dart b/app/test/extension/src/contribution_test.dart new file mode 100644 index 00000000..75916daf --- /dev/null +++ b/app/test/extension/src/contribution_test.dart @@ -0,0 +1,62 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ContributionPoint sealed hierarchy', () { + test('TabContribution exposes slot + title + i18n fields', () { + final t = TabContribution( + id: 'welcome.view', + slot: Slots.workspace, + title: 'Welcome', + titleKey: 'tab.title', + i18nNamespace: 'builtin.welcome', + priority: -100, + build: (_) => const SizedBox.shrink(), + ); + expect(t.id, 'welcome.view'); + expect(t.slot, Slots.workspace); + expect(t.title, 'Welcome'); + expect(t.titleKey, 'tab.title'); + expect(t.i18nNamespace, 'builtin.welcome'); + expect(t.priority, -100); + }); + + test('StatusItemContribution pins the statusbar slot', () { + final s = StatusItemContribution( + id: 'ipc-status.indicator', + build: (_) => const SizedBox.shrink(), + ); + expect(s.slot, Slots.statusbar); + }); + + test('CommandContribution has no slot', () { + final c = CommandContribution( + id: 'theme.pick', + command: 'theme.pick', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ); + expect(c.slot, isNull); + }); + + test('TrayItemContribution pins the tray slot', () { + final t = TrayItemContribution( + id: 't', + label: 'Label', + onSelected: () {}, + ); + expect(t.slot, Slots.tray); + }); + + test('ToolbarButtonContribution pins the toolbar slot', () { + final b = ToolbarButtonContribution( + id: 'save', + label: 'Save', + onPressed: () {}, + ); + expect(b.slot, Slots.toolbar); + }); + }); +} diff --git a/app/test/extension/src/host_test.dart b/app/test/extension/src/host_test.dart new file mode 100644 index 00000000..57c1c0c0 --- /dev/null +++ b/app/test/extension/src/host_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:clide_app/extension/extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExtensionScanner', () { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('clide_ext_'); + }); + + tearDown(() async { + try { + await root.delete(recursive: true); + } catch (_) {} + }); + + test('returns empty when directory is missing', () async { + final absent = Directory('${root.path}/absent'); + final out = await const ExtensionScanner().discover(root: absent); + expect(out, isEmpty); + }); + + test('returns empty when directory exists but has no extensions', () async { + final out = await const ExtensionScanner().discover(root: root); + expect(out, isEmpty); + }); + + test('discovers extensions from their own subdirs', () async { + final a = Directory('${root.path}/ext.a')..createSync(); + await File('${a.path}/manifest.yaml') + .writeAsString('id: ext.a\ntitle: A\nversion: 1.0.0\n'); + final b = Directory('${root.path}/ext.b')..createSync(); + await File('${b.path}/manifest.yaml') + .writeAsString('id: ext.b\ntitle: B\nversion: 1.2.0\n'); + final out = await const ExtensionScanner().discover(root: root); + expect(out.map((m) => m.id).toSet(), {'ext.a', 'ext.b'}); + }); + + test('skips a subdir without a manifest.yaml', () async { + final a = Directory('${root.path}/ext.a')..createSync(); + await File('${a.path}/README.md').writeAsString('no manifest'); + final out = await const ExtensionScanner().discover(root: root); + expect(out, isEmpty); + }); + + test('skips malformed manifests (non-fatal)', () async { + final bad = Directory('${root.path}/ext.bad')..createSync(); + await File('${bad.path}/manifest.yaml').writeAsString('not a map'); + final ok = Directory('${root.path}/ext.ok')..createSync(); + await File('${ok.path}/manifest.yaml').writeAsString('id: ext.ok'); + final out = await const ExtensionScanner().discover(root: root); + expect(out.map((m) => m.id).toList(), ['ext.ok']); + }); + }); +} diff --git a/app/test/extension/src/manifest_test.dart b/app/test/extension/src/manifest_test.dart new file mode 100644 index 00000000..32d0b740 --- /dev/null +++ b/app/test/extension/src/manifest_test.dart @@ -0,0 +1,69 @@ +import 'package:clide_app/extension/extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExtensionManifest.fromYamlString', () { + test('parses the minimum viable manifest', () { + final m = ExtensionManifest.fromYamlString(''' +id: ext.postmeridiem.linear +title: Linear +version: 1.2.3 +entry: main.lua +schema_version: 1 +'''); + expect(m.id, 'ext.postmeridiem.linear'); + expect(m.title, 'Linear'); + expect(m.version, '1.2.3'); + expect(m.entry, 'main.lua'); + expect(m.schemaVersion, 1); + expect(m.dependsOn, isEmpty); + }); + + test('title defaults to id when absent', () { + final m = ExtensionManifest.fromYamlString('id: ext.x'); + expect(m.title, 'ext.x'); + expect(m.version, '0.0.0'); + expect(m.entry, 'extension.lua'); + expect(m.schemaVersion, 1); + }); + + test('depends_on list is captured', () { + final m = ExtensionManifest.fromYamlString(''' +id: ext.a +depends_on: + - builtin.git + - builtin.diff +'''); + expect(m.dependsOn, ['builtin.git', 'builtin.diff']); + }); + + test('non-string depends_on entries are filtered out', () { + final m = ExtensionManifest.fromYamlString(''' +id: ext.x +depends_on: [builtin.git, 42, builtin.diff] +'''); + expect(m.dependsOn, ['builtin.git', 'builtin.diff']); + }); + + test('missing id throws FormatException', () { + expect( + () => ExtensionManifest.fromYamlString('title: Floating'), + throwsA(isA()), + ); + }); + + test('empty id throws FormatException', () { + expect( + () => ExtensionManifest.fromYamlString('id: ""'), + throwsA(isA()), + ); + }); + + test('non-map root throws FormatException', () { + expect( + () => ExtensionManifest.fromYamlString('- just a list'), + throwsA(isA()), + ); + }); + }); +} diff --git a/app/test/goldens/clide_button_goldens_test.dart b/app/test/goldens/clide_button_goldens_test.dart new file mode 100644 index 00000000..8d495230 --- /dev/null +++ b/app/test/goldens/clide_button_goldens_test.dart @@ -0,0 +1,85 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + goldenTest( + 'ClideButton variants + states', + fileName: 'clide_button', + pumpBeforeTest: (tester) async { + // wait for hover/press state to settle + await tester.pump(const Duration(milliseconds: 50)); + }, + builder: () => GoldenTestGroup( + columns: 3, + children: [ + GoldenTestScenario( + name: 'normal / enabled', + child: _wrap(f, ClideButton(label: 'Save', onPressed: () {})), + ), + GoldenTestScenario( + name: 'normal / disabled', + child: _wrap(f, const ClideButton(label: 'Save', onPressed: null)), + ), + GoldenTestScenario( + name: 'primary / enabled', + child: _wrap( + f, + ClideButton( + label: 'Commit', + onPressed: () {}, + variant: ClideButtonVariant.primary, + ), + ), + ), + GoldenTestScenario( + name: 'primary / disabled', + child: _wrap( + f, + const ClideButton( + label: 'Commit', + onPressed: null, + variant: ClideButtonVariant.primary, + ), + ), + ), + GoldenTestScenario( + name: 'subtle / enabled', + child: _wrap( + f, + ClideButton( + label: 'Open', + onPressed: () {}, + variant: ClideButtonVariant.subtle, + ), + ), + ), + GoldenTestScenario( + name: 'subtle / disabled', + child: _wrap( + f, + const ClideButton( + label: 'Open', + onPressed: null, + variant: ClideButtonVariant.subtle, + ), + ), + ), + ], + ), + ); +} + +Widget _wrap(KernelFixture f, Widget child) => SizedBox( + width: 140, + child: harness(f, child), + ); diff --git a/app/test/goldens/clide_icon_goldens_test.dart b/app/test/goldens/clide_icon_goldens_test.dart new file mode 100644 index 00000000..2b9dc6e2 --- /dev/null +++ b/app/test/goldens/clide_icon_goldens_test.dart @@ -0,0 +1,44 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + goldenTest( + 'ClideIcon painter set', + fileName: 'clide_icon', + builder: () => GoldenTestGroup( + columns: 4, + children: [ + for (final pair in const [ + ('folder', FolderIcon()), + ('gear', GearIcon()), + ('close', CloseIcon()), + ('chevron-right', ChevronRightIcon()), + ('chevron-down', ChevronDownIcon()), + ('dot', DotIcon()), + ('check', CheckIcon()), + ('plug', PlugIcon()), + ]) + GoldenTestScenario( + name: pair.$1, + child: harness( + f, + SizedBox( + width: 24, + height: 24, + child: ClideIcon(pair.$2, size: 24), + ), + ), + ), + ], + ), + ); +} diff --git a/app/test/goldens/clide_tab_bar_goldens_test.dart b/app/test/goldens/clide_tab_bar_goldens_test.dart new file mode 100644 index 00000000..efd0c25d --- /dev/null +++ b/app/test/goldens/clide_tab_bar_goldens_test.dart @@ -0,0 +1,60 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + goldenTest( + 'ClideTabBar active / inactive', + fileName: 'clide_tab_bar', + builder: () => GoldenTestGroup( + columns: 1, + children: [ + GoldenTestScenario( + name: 'first tab active', + child: SizedBox( + width: 320, + child: harness( + f, + ClideTabBar( + items: const [ + ClideTabItem(id: 'files', title: 'Files'), + ClideTabItem(id: 'git', title: 'Git'), + ClideTabItem(id: 'tree', title: 'Tree'), + ], + activeId: 'files', + onSelect: (_) {}, + ), + ), + ), + ), + GoldenTestScenario( + name: 'middle tab active', + child: SizedBox( + width: 320, + child: harness( + f, + ClideTabBar( + items: const [ + ClideTabItem(id: 'files', title: 'Files'), + ClideTabItem(id: 'git', title: 'Git'), + ClideTabItem(id: 'tree', title: 'Tree'), + ], + activeId: 'git', + onSelect: (_) {}, + ), + ), + ), + ), + ], + ), + ); +} diff --git a/app/test/goldens/goldens/ci/clide_button.png b/app/test/goldens/goldens/ci/clide_button.png new file mode 100644 index 0000000000000000000000000000000000000000..dd315388072e55013f842b794da08e4dbe0df830 GIT binary patch literal 2353 zcmc(gYc!kL8pmJKL0#$=w^A(AK}U6Hm`-TgLldfQlY~|&QfX0l(qfWQ9n~g8V^yhf zh}I>M7*QpzRa%$yxMvze5e}VJno3P;UW$_Q>RD&4IcvV0^I^`}ANJnQde(aOf9?H$ zew+5KkGqEIZdCvPG(3)@aR310L;G|UC1~DYemx5xyDPvO zHhB#IDpMY47r)dJ*|aac;fUb+61! z6jAnqa6`|r)5x{XyO*#p*0Wh=V|}d>wC)?V8@Q3HG--&lwvdh+A!HYH+Xy?>Zy5SJ zm-Fl+0Q0+*1gX(V}@k60wYx>+RU?b$yCrig)Sv4 z8d(=()u-BVBN;<_wbsR!D_gG*VZoF5y0a&2nR3eF*78zCocPYRyRwTI)~_lIgL5q0 zIIDJ9#gQ0%EQ8uA385fBlcGF1GmOqfVKok*v8y=EuoI5wt*#?hdlx4uhdyk0!9l`& z``^j^!a((@)jqaw8MVXeiZSNJ41>WPcwQZpV;>dYviiANuYuY6t&uX|jNoQ3<;D8~ zHBLlcYnv(NbQ5mGoHRE7C^B|7Wncu+{=PXNXwCfkvG_)ynbEId+y&AC34wzLOsLPZ zV9g&7k4?7MKS)ACpa^=PM%euUB>rF6@K65eX&zl3fF3F1^|PMfg~Iu6)D66*!o)!} zgAfM4*PS@=$O?pK*uJRz&oOq=U(_M_u_XKF1Z4jlS&H(y*Cy?&p497D%M{)&6_E(# z;p8PW1f6Zsem+@;3C;;T!WG8}=7(JzUN-%U z96^~^dVe@JtKT?1x;`A7xbnAnOms^LS=H-I=TUk{omFHjk9DDZJcyBg#c-_4@s_$n z1vOmDW}FAHqC%^gR(EaYl!k`vsa2IPDDuvxefx6+EqrIfA%G5s*3iZ=@sC8aZa!iXQ^vwhWKyYGW%y6o8byzwdo++$)!|O<_t? z`cB=HS5-ugRafl7+CnIG2_<*^L>3d2jyuED0|jF}d7Q}*_@=Kfix4dswr?rjAoQb) z)aX}Q0`AYJ$-fb6TQx+GCeRazgF)(O74Fd6D%?NsQ|e?CDxbV3LI813J^nzq+6O!* zVMk*LSsBlxTtG^>`D7)p0BwYfulMUvw(9Ry}m}vL6;MlqFY)d0`XD zxqD$4oAM&snk)xfc;vnS6;vnCg!OH3 jGI2D`Ze6?yDGJzvg%O8y-MEv35EuXtj1QXUO8EXC3<&+0 literal 0 HcmV?d00001 diff --git a/app/test/goldens/goldens/ci/clide_icon.png b/app/test/goldens/goldens/ci/clide_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ed6cf4d237c30c10015b5663c7222d65f7b6689f GIT binary patch literal 5013 zcmd5=cQ{+`+fPwisv4VARFr;O8nuGbXf;-A?-e6zQ`D+au{Rw`P<*LXH9~98+N+|J zP+QHE7>zyOLgfIw6( zz<1|Mw7{oyceof3Xgm~D^)3NNz@=wV!1#iPj= zb#3iAr>3fj{W7Aud9BqkM!1*x#?7dD1E-Rz%qlpmX|-~Do{jz>EaX!O3|_L5XV|Q~kP;`Yd^wsnwD`IO`@(mg0LNDZWQY-)LN3Sn|N`RT~Y- z+Js%xb_PDUfb`%d^EKW-KarM%l>y=cvzQ-6H<=r*7L>(*G zA)iOU^sYfp>@_eL{8m@jo&|`a7s3%h0JCWQXAH{%HtI$dlsNd9Ns~!VEtniZWZ#Mo z!Q{SjN!Q#&Y>^|izKaq!Iwnd&w`D@gba+S*27_U!=v{yS6U_SYe;8Uy)q%h=t@GY) zpYowWbkDD&+*L1rsN%_TJ9_lz5495R77RuUWJxrogKE_5M<@h;47G+Hya`RJT)3h# z-^zyuT?$ulp3?70!|!bk%@8@!rBP9wsMpGhSKbc}_H95pJMg9C$O(t#-gGsPLf9r< z$gwz9Tv4`{G}#h6;&|a3`HOWqr&$fEVS$F@YW=BVRJwZF)KGG2a<_~8pDxldK1I$B zymLBRU8Ht1Zo>2&YZC@3bn!x6jndaIAO!topEUI6| zzx{;8d#lwu(Xi)t=@$bu`!iR{VT=wy}QuM;i?`DX)BiRaVD4Eb|6ljgjrnSyFKki+s7I$Tu-+WBy}Jc!mv)wO z`)RrE?}yCR>YmMUkl_`cYF#Nhy2b%%`(9rA-%H@YWw^~0e`kf(zLIull)?d1hI&Vo z5_~vVMQb$a2&A--2?x9J3>h_lUoId4oai(ay|1Pv>;&@Q?t9r%qv{VN4;;&-%tPKV zXmpGO`4mFuUQ?C1$H6A<;EmLb>*LR{raJMfSnL@!EcaBuEQyo{mBZ;)r8bt2J;oEK zk(oUHcT%PO!Me|Ro@|u%9Iv!yHk|Yb<}ej@G#m^nd9RmXhEkk=W2{rWM=#}nH}*7h z>+4ms8b|ZH$&zV8`0@wPgds_Rh!GcW;NC2m{mXABB7Zg;%3`h~piM5);uh$Zk0RdO z=uDMidU@yrBmKTOLj#H0`NQ8|Y%e0fGPO+)#)sfC$wid9uT3>^XT_5m2DbFTvzGbI zo0ryqx|Qk>O+&?3wf1>->Z0O4H6I3jl^(INJSxSM?=$)gbY8>(6#+s?Bgh=4q7GrS zZtHe!U#f)QkxJ13_F}awZIvJP4Hv#t%TksaXhgaFV~k*YkFEVo0#I*)r!dIg*{e?F zzN+&j>U}YV9A=Chg$8&<91342_vFJ}VDY{WL@S;qFg6eLs__Ylhj%T(3$pCx@p1uN z)6#Y?k2X&;YQ%0OP849{M(F2Vxv0K1g+}^fwMBVFV%NTT4-d{FthV1MwJ_SVj^#Ji zgpEMIGudT5QMmYpzr&7K`~G|*~N%|BNDqCO@pIj}e=PRa6;a;o-Y zGvrSH&=XdtGAAiRMuvL5ns8FQ6rm@^t3FlM^)Aq-qNC}3--`9lGqw?6(tS&)YKzqC z5^%7Cgw+U5T)qqBWtanZZ)bO|Cc`^_CW9g9r{mog%olU)aQv_3W{&OoWq0+cHanFe z=iNF$=(%28EFjauc2!7u5kK8Cj~8~`u@-Ip6YrmT_?sD8%THI(M$1_8h~D|G5NpWt z^nOT;Bd>g;977NUPFCL{yx4IEo;giMJD1H%Q`7myL{A;dm=ACy9@T+>nzq6FsgMc>k{K>TbvcQagCczJ~$qm!wRKFv)HH47Y z&IJX=*UAOcNV5``yWN=Ja;fwIo3{E0d4Ebyd_bLJeYRIL3KEWZYmmI)1RXE_>L*ZZka_cZpft;rY z8xcKoXJ7n?&5PwbcT?o9{)Hwmp!a%kCPd zdvE)q9adgfF0JYqluq=QHSrYUnKp|Y&TNOX2%hBk4{uz|ZlH`r z01KGD11Ko2bM(0j#gONN@u5Ib$cruvNLaz2%y0-e_o`V|Vbz?)EjRjdO_xV?kjxm5t zq$WR_8uHE#$_p=282z?|F*p6otiBZFzU0uHO!QF-lqk2F8npkQ{3KUKA^VzUQ&Wr# z3%r$Fp<9uY9{rWVh3&HhHBAS;c^Y90y0;uJQUkk{*g*N2DrYAuqTUo{&ebF5XLnV1 zN!U^1oe1+(AR6mzz!2&9C)F+H;`}HFZtKI0$TNVvUQS*O^mVZauW}{P@z+OEo5l|e zgbs-0TT%jZ`k15r=8_SSQ{%;wDvAsB6SdVbqi4nsHO?di2aHiV4T-_Vg?iA>{AU!N zxW+lTknC#=@-rF9pw98s+PuEb*O^7n}z(Ae=3}p^(_mFo~1;ZsvN|I zu-&mZgYRtqqhlv`_o89W=!66nq?UHyV-ex!#9EHG$#x`#sK(Gd5n&ie@{tGFQoQqVAvo#q!>#0m!U|C-Xjs5E+md27upFNkd~ znwWhk(m|J1V`cfNe(tN~Ncrg7Kc0la!Z2ZPVZJl2LLp}3lMy58$R9fv?PhCDJe2s- zM>l^Pn?Y}c;k`2k^U~c7>|hC1C(V=%BZXP~9VGwqKdkhaa(ndm{4uiV*yPrJujh$; zYD(2fx%3Bf5u@D7UwuNu|ElNa31BXSyf;KmS6oKm>0Vb4_?%_;R_;Gxfc?Fm;bI17 z+D$P6u9VR<;YPSd4BAA6kqq=uC{#NQ>o^clY7ev3{}YDa)_D%21|_3Va(0U*Wn-JR zHeBdlq)Q zwe%I~iY#-;OYfqEMYa|6k*Dq}|=yWA!? zWAZfi(uaPiCwuwop z%%{#jtEkoft)Ki+_r3Trgo&(!*{ZR2t6i&5kI}be5gl_MFb_QH%45apf!-{5iBdA< z-m#c4+<@z3KbfSqNTec_>u(FeU&ZdKFTATIQNK;d){M*QCB#^0v!}R0MC79!3HY44b9|~ zdKVT!ln4rwzNHxl;ew{>f5FE96WR?q?EA+RVM?|pROXs$i4%>PoMJye7PuM!4hNg& zEr8c(Ouw8HIa?+*RlO1M)ZfEK!*>6bZ_?|Yfx90M-Oxh%kXk|c>L`d{Lsvi3No>7c zAjVS$ro9nn*xLKpD-J98?@*}ndMWTWt%)^qa?k028U;Ml$}r+;&`@Y3*O?kJDbh2T z^{x2)aq;e)!SOi-i6WS)SNB`iIT%T;{f0Z2oAFx*fGe=t9~USXz?6f^0KHHo1zlw_ z0=P_l;;O`&wGp5WOL}h0_ug?#*>^^2&R{Q0OWy)07uqb#abt3@`r~+4yB+0r(InLfS%Wv@8{PWAO!!SH=j$I1ufG$X<`COA5LT6#5hCV5mEvh>NF(I2%?lMxd zqZaBvNHxEaaSa??aV_awUM9(7I~xwdBZ#(PjW&^-dkY1u8^TOjBC?}RycycDUGL%E zaot~Qb7-dN*}*uPR-e08LA`+;Y%SrW7#7lhl#29`VLEZ-G$;xOyddDAiWiGmqQ%a> z&wOvjJ-_VpXsoGT?6dO2WW$4(vwI9FeiLqoyY>u>)Y(lWvL<3{Jp>kd2iVVf&~M)9 z*ikn%d9dGQRKi|a7G06$hb_}y)u|{d+!41Sz&dBd!sbq7CC3ri5pQ$fYEoA(4EA=R zg4q45s$M5p9c6-Ul<&6oz)^an3?hsuJIKt#pLgyS-h^pCX6THKx*ev#cm4}~%-;Nt zw6D)2zThn84^b#40BS{(m~hUbbu?h`<8lK2M#beMpW`tf zusNa5bQ;SU8Bfhtsf6Qa-Z|&9TvcVmtZU%;6M0`Ip6DVC3lCsl4Wz25fhtw74EZnK CPJlT8 literal 0 HcmV?d00001 diff --git a/app/test/goldens/goldens/ci/clide_tab_bar.png b/app/test/goldens/goldens/ci/clide_tab_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..36457abfcf9d1c5a13811df0b2783a176b60cdd9 GIT binary patch literal 810 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoOE}noV7lVz;uumf=k48%-ct%?j(>chUwdC~ zZWlcuF(WGK^_R-Me)HK3+*kxUR2v+07+I1y6$HE)8Y7rEjtDs@Ok-f$NUYTAm%qf+ z8VVDcv6~Tke_nM#L{$gN-+w7D1sfF2;HF>g`PIfRZ@<;}q@=OLo>lwu>$BhfIojU8 z`lWVwwDtcj+sw?PZ0jpiw_g41o$=c^xdfq=R5wRIw0aa~Az_a&AvFAb{SV#^%*C&p zUmd!a_tLNd7@qNF-sc4#7UeHmrS9d=C=+^r_wN&I9CNgVuZyj^cl4!j{Pj0~{$4%l zz!1CB#5mYAc(eMxbMqA_3qDYwpS^M^>ZK$@*9J!H!M^JGdwWYATbHK4fA_w6@L<6g ziKAbBPhRTYkemaLyw#Flc~5Jve(O_oET{f&%-cUd@80^EfBk+aH+T2ix%E-!=iBCf z{qy;U?d@B4G;Eoi5jshY_U(sCkE|`oYfb)h1D;^6o!EZpV}0GeirBaJb_G}UBt5&o pN5_^)CYkw-$KBQ5{R})m*`Jl#XEaKDQ3fV<22WQ%mvv4FO#l;IEOP(= literal 0 HcmV?d00001 diff --git a/app/test/goldens/goldens/linux/clide_button.png b/app/test/goldens/goldens/linux/clide_button.png new file mode 100644 index 0000000000000000000000000000000000000000..a489d817161bbc1d9509988361a576d85f790a3f GIT binary patch literal 15778 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 I;zmLL4~7Baq5uE@ literal 0 HcmV?d00001 diff --git a/app/test/goldens/goldens/linux/clide_icon.png b/app/test/goldens/goldens/linux/clide_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6f39bc98a7a1aecadb27b12dd232bd5d0b39226f GIT binary patch literal 11777 zcmb7~RaBfolZJs1Gz6DHf(MsCa2?z=!6nFG!QEZM0D<7{5?lfV4Gw_;26uux1b5gk z|K995d$T(i19O_8zpm=8s(#*zQd5z|!XU*!KtRBfmy^;!KtSXHez!+M0p4Y(LqmZd z$SxA{AJBl8FPeEI@EOrXLskNzYLt8r0f7oZUJCrdGxI3R!r>UFb4vp}3sHClC4H9t|YkzV>0| z1o~m+cEzTVk*wRNW^L`3{W{2S^3Lt!s}^p{QLnKymldxupLEXa@Kv3;%yYEj@ai?f zg*q{=`TSl-kHcSy!pQhv81xgr^=m&93r=$MBrbhp`vRNuG|sd|w*_8r6Q~o@!w6p^ zH6mJ?nYkRpRm*gDV;1*To=6Nc0s>bIM5`j9u{MHyOA&hxniJxnt4J}vG31UiU4MFN!MhP{dtXDP!RqrFU4uIN=79;=#NsioPeFJ z?I<@=#1pal>H1*jVMCD#iaweLrR)_U?V!r8Ls?j>DwUuwzq2+iJrULSs1juyGO~=a zII310^8st+o>%YjOMRL4xuS9Er&wtN|NIT2%F?2KOE8Sw_2z*8bFFRXA!vXLjDuh@ zG3i0%>!>0174`{pS;(KXbsTtZI#u3mO#3)P?uGL440pU^(ePb48I`Q)bWutv$P_ZP zF~kZ>|D6x%*Mu)>^(j~s;}`{eM=}ocAs}1nGopM8Hn2h}5hh;66N?sqWTru|RwoD^ zANqL)t3(e)6MItp^HTUq8`&^ge&Go4ZxzRmOB}~;-#brS=%^{4fPqoCH^T6gdP1~% zT{6vg`A3=t&IRW2jBBa=KyE^TpUCuPh{2^YA*rtRz6h3GNjk|31ztc&gCT{fS0FJN-$KN-)Z)KHffDWhHeTG#yPA+L4b22-uGx1$R{c=JiipxztqNQ4}@bSb$O>=J6ip|Nd2pnWO%Y{Kc^ zpTZXacjsBK#h@2A9Wyxoy_Q>9l6$EoY$dzeJ<%HP#~t71)vinLdGir4XK7kAu&_^I z3urSH(pNC3TNo|YXFd|dDJUlGvbp8&$T0s7DjkzdktF2ydUOiE=<#?ywaJ}=Lf8No zkA&=VEaXOgf|yF?u_$?M!1xp?c_k$M$18zaoPu_wb|(gfeezJNzFE>nTwo{T8TZp5 z^8}68Hc^pFsHEd2{i1QTr2yO~C5vaX9mY>DewC|>_w=Ikg`zCX0J_N*SzP~u9>N<; zppM~vZjU@q_)!N}8YJ9CRB3NkXjGVF5+;fK*$AxGE{85q`#?r9eRKUtN>8t+`jKPd z)Z^V_3?oxft@rnTUOT0+xqHdDhY2P?*Lo&|$471snNW=$V@PE0?HbDdJ$WQ_mSDHp zHkFFNuC%8u?nI~mNrH?B>|=+yV2~{sM=G&_OR5C>sMiM`=adsnqWoI@^poocS_Eee zbz{**7EZnE4dxaiu*SQ`OOi@dG>;r{c@{}vH*St1fXQ7Jc@~&CePT@-v}qWKX&H#k zUg`nHH zxvSFr@D-#Zu(eMIaLs+VHq}yt-bxJ2Zm>4v@mN9*TXvP+s-2cJ*XZ@|a$*@2@zKcP zGL&cPh+iLbhdtXo;~bV>kMhn_~Jk&*?w|i5yM~MWM+MNq} zY2}uIfH+%0b~ho@!MKDKEC zWw*!<_dDsz!qK-n_ks&i*C=6>mE=XC@4p!N{|IP zUOORdPW^P6rPr466=#92G`|oiLS{AHilw4uAnIQS<2a=(@ynfF=nW>X;OE3L2(64+ zhru)4b1hgSHT{DR`vK47v-r)mk=PN+MTI#nLlAhrqjekpcwBb@A_!+lW$kB$1z|jn z77nc)=xC458csq}Zgn;YETzhuJQD`81+^%Ju!Z{U$4_qeZ7C}ZcJUmTg%IQ=z89%t zVQHcH(#`d-kszs({sy#sP!^r^1BkNoe9emdHHI-wM+z%#sS+=62(UFiyvf(n3edf8 z8p@lXtRolt9N+eHP(JLj3PUctg1RS8E4GI^&UNyY8dg&IEc2{Vt@0Z;I3JNLepol3n^ zOaGdMymITUD$^b2e1ZF1 zlgULUz%=1`&)2x}cUkBSnVuFc|H!2rE$N5i;K&5`u1K#)6sU}Otm12UR&3^nP`GZ$ zCX9B;?-epSQ|+fI0@b3Uew?Dz5nwj}hn~fKSl5%#=IRW-5&5kQ3!dbJ5khjh#ZOMl z%u^$-Vdi&oqrW{qi;6}CHF-bHq>{!;;u(hPmbigZ*UP_$?weGzgsE;1YxSy{)9oMhUG3G zb^-^_wDaW_ows6epd*R}3$%1fiU(cspnIYPcj}3BwflmPCT84i_vk#sn~w(66aE4x z%Q`i$u@odIFA3KSUAZ>5S*GRH0K3bI5+&pAalHmg4axFVXzOObCnc22RohNYFh7_| zpr&E8%PB$-fn^&L6uCJHxL7LX@NydS-&8(06zT_KT(m^rH^t93sl1%kxQS)Sw<4aP zhb%H@=G2Tv6^>}&(^n+R6!h5Y^rm~uoVghglyutjT9lh+8K?uf^I()Ta{2QYxEpf2 z7j_lMY_aCghlS&>rvkp5BR-@)2ROF1-W?w%UPW+1tC`Y26M1C8*2TKe!rsaOX$s0}@_ZI7fuE`4@Xinj4gEG2Xoo6IHSZ{!y|5Rl+fbHM z@Qx#(4#-*jVLAoOtPAI#J}AP{g6ntnf_bVxfxvVQZ!Ai)#%tXeL71}HJv6`h&)9)# z*b8|+<}f1)f(+kBiF0OTs(&}Z{wv^b9RE3ov@68ahQexKroh~Y|1D*(GBP~v3#|uF z*TQoTmp6{cd}xBi7iQ#EL=29rV zx*XItb_ZhX7bYH-@0>CcFum~3%3vV&12rHnGJ2)8_S8|$r@5WYO;f}SHEm#g(nC)yw4#m)m6ly zkl$5q1qwCVmPw~g!|-(fuG0G3txde8BEAA1Tkz+(;6t`w?~d?TX$dIR5}ihGa5PLs zWrjsZT40gr^ZXc9;vmj1mfV7|)+tnsZ!U@rbt+NSG!&d*4^|E8S7gf0bZgA*pmq~7 zy~pIiNCQ*EY_FzEtT9YHAo)s^FA}67GodkBLrMi$;QK2BTu24KX$8MIjXe?Q^$T>J85rhLbhPCe}KRe88O4yYWCp6W;OS~A{%`6U3hG?!pKtD5}==ZJPPHO!W#!^KOr4CQxzgiZ=qi+p!emdlEl|2$(76#QesFD}QH46GFT zWy(Cr=s-zBldre&gAV^@dIZrzp zxqNYjgy^@66C$5U>NPU54>oedcmoV9fBD|;oaBHKf1Q~tQcIan%~WLjb_D6tBF(3KQbe^E|(J>1&egrH#ipPGuE1?k$Zt|A2H%NevX zJ0I`ltX8d?r4XL0Qbv)F-|u0=w)UQvxlPW{kPJF4-bTGiia61OiNsYGG}@c@@jA$m zVy`&8Q~9`^o-{eeT$G%!OUEFB2*zo?O&9PKVODK#cA0w7Xsg0e2R!LT(MMVo=FH^K z*~+u0s*Sb>_vzAj|BV?4ozH;#ehh4H;|ErRI$@NMXzf>r6>nTIFL%rVvR1|s`Sm`& z*Aw#vxM=&H1{fK`2Rik1Ir>#;_&!hJ!-{h?_sI9(2i*I2taONkZ@XW2oX$h&==nWw zj(L{OZv<^(Jzjx2Y_$Zj{nt#%ze~+Ecy4b+Y1ZI_%`$SHeXaSPeS3Tns`N$hQzWx0c8oVr78P(Z63m)ZOm+hnkx|`>e zS!0HLZ0q`BKBzsM^Tw0ii~4a$!tpmaMaSE^Ftc>1wEpHZn@i)>s zHpy*~#(L#E4dS`XEY{(|W9)Zv1SVR1SwC^RLQ$?}j=RkH^SSJL&a1{r!aP;(>H0|F zfjXH`)WIWB`Pb&G0PO{D5PPI!L=?9qY*#Vc0Loz7a6s*(dwNk_Nn+< zPTnHcjf!^8sc9$Ck6EdI{s8`D6q_2EedCw0=%DlK^7HywT-)~g-os&>Q1kVW$88U` zn3P8F3tHQ=?QK0Hzs~Tp@7w4pPRor2yWvAk4y}>M9=!usKL%#j6dnKmBG#f&q)C6X zy~406QckGHadOBnLuLMVd8}GIUYaP;{BJ>kA*?Cj>Ge~t==^$?_ME0*F%|bY4MWQ6 z*^%~FNPM{#fpcM1bj1g3z!LJnS5L<5O;kT}6LX~YcRp-{ExG%Fs_jbGiuhQ@-M-E8 zQ4}*cdXsJ3x5l17mhhcbF0ywYeem2I{`?h!E>AFi!MW;d`WSk9K8g$&`@E82W&nj( zAzI^RX!-L^zv?V}p8HPziWfhmUCHLln)BeMg(srt@%`-C0czFu9Z!8}PRsLb) zarsVr*1+E3>)NYjo7XwJzqS(7lGlo=enu>cYV=2A8;`B}yGtG#JFoA7ZoC3!ot}=w zRtFkb$-hJCFhUz#RP!!1{7ciS#TwbKz>Mk1y(wZp%884J; zLHwMv2h2(ICWGp&jiXd{SzVK~j;tk=B;U2@`uMe1O*Rj+}Z(qeRC%t za-sutc(Rt1IAjl65p=j1q6yDS)Pcze&s|bQ+0X+wl$TjqVVl|G3xMd;aTNn zrFKGgXH}_UF%GNlxTaGQ-ynz)%%>p%KQ}WQX10}7x9*Mg!Zc&vSN1Rn-oUHFDFf@% zJZ+13UjoOB-le|Td&@O>6efX#{JT8;cEQY{zN9XyPIqtV{^yp{SlihsH4tOt47HRr zf+SP%9@Gz{xUWJE2E9qcn21(;Xn(I^vYc$uLJoSp?VX+}!^uQy8eb(86266N ziEVvU24d^4Ci~XNl$8o92DTu@l%8kY4z3t-8;Ou^z z(^i)_oI7p{-$zn=89v zGYmUTFN)RMTcJjY47z{Dx32m78Z$cn$#IKHUw7t+n@&{ z82ZOli|~5#ApSc3{r1@Jj6y<>M4ITfiNnZOi@7n*NN+?t|CU-1_d4j=bJa+hGiuPn zwSi^+U~|BuD*LOPAKWeH>a1yKBS!g$K^tcZ5CckUs3WZr3#lQSbUENxRcX8yngO5917@Y7_PehrZiGgJh+}Z6S$Bm zB>eQi>;Dup>~-OxKVR3BeX@)@-~4B*seEu4Is)PDe*1Z~e4A2?KjS1|&$MomW|80{ zgVEM*EQr77(2z!Q$V9P&-pWim{}J~zZp)IMi*`&_9*hB_h?lwD z#1uNbjN;WqnxFpRQ3t6>L#yD_G3h@O&1lIj!{e0+XhmpeIeU(hn$fH2Wk)_(S}u~P zam`#N)X4`ll{qM);#Y2SpHd4ODjhBJ;gb3zKfRfGhDpzw{AYobb4O9?KytZEzIoF* zC4}IzwZGafyzQ!al43ik801f+!RYiouL)kru*m@hh(3uxH#j(H*F5w4(ddy)R zDgT8fYXPdqP>j=9SnLx;K_!yv{0dFB%-vUc%lw{&{*=)5@x8cAzX%ldx3EsV+zPHFTXO+U4Hi1lMcmQ$~I-rE)N9q1^ z(ar^Skacd;aSw(Y)xAZoMYXtVOwGvn``x7rs?q$<>h;O}NwU{hvQ8;0p_v8~3=XOy zdjHgs2;5~*DJU8+B$~%GplLvqAbuVAwNpwVfAUh``+Nn3NoymWsS%*@8%@PdF}v%tL(u zgXffdyxOnp1_UGZiQnsq%DHEqxKqfP($9f^B0nqu1l9XWG^FSr5bVG(qj0!ZROdRj zj`wvg6ouIsS-mUXgDIaO;uB2oV*5-TQ_f*I?)ld_S{}GYKVmni zfRa|41~6&EaKhK?tM{+gs;od5cnS(kGM36q^?*zS@X~or;#Q(7S&L^ybb*ag+16 z;KbL#(*a#vZkb`)8<|cN7Bz&=U8`o+h8xUrCc{kQ(C1$+sd>EQKg$72(NLE3Q{dx0 z-v+6aFAEZ*%WcPHAli>J_1+|Q!N768Rxh{S}8756Lee#NSn0u%5nG;x;Nmd z3jdjS2p$0F1&JKo>WNJ*fdS5*-=qg(Zjv z+)TXtAA*MI-r$OFi^roddnq7Oa!;v9%14!wQA= z3_~;ks1U9z;s(6QoSo5wUX0~9FYVyd1j3DCG7a!nrwOE{GPmF;Q#%6*-z<95j`O*l z57Qx1oy}S#?M|r93i9SOjkvvB!Bm5TQDLF2akBX-h5*6kTwpPC3i}UZmpi_JA@m`3 z_B_b|cpn%u`+_3=0UtQE`8+bregTEOPlP(+&X%Sq`4Ja-7D}V3Z8`wWNPUt?yue(a z1kEi2a%?{ZpdW6uY9=WOB?AovIsbYA@}UW}k&c#ZCIRM?fh%0E?`iLY)0PJSwx-UN z@~PoCP8SD|{LGD=It%g!zQ&cl#H7Nm;X+AX^E4Nwui*5ad_4ty`R*k$)u25oo~?+ZmW#bvM{B?-^~R;Id?kCl58irBC5VUm0H{x_ zzjjMSe{k)?v?tjCC39nmA$XJI;n-G23WYO-P0~Mz-orxdC3j|s=-pXC44exowTnnX zc7@J$6V`?PcI?ajV&}6!=1WQ`HT-e?>w?1MzK`8`H5ZXT#m2uN;$$%a38Q#Tmb}|| zlA$pYA~Qpmu1EY&xotf%bXKkS{62BDM67siU`h~&Vkl%Q>bI4(;h!N1HGIGbw1%+4 zJ>0D%K;QhQ5s-6_Lv!DLYWN5}I#}hPM6hs}zxYg2!hBx#DNQgLm!$61Rw2kNa&2>8 z`=k{=DHIVN#b{0Z%9~y19K)gpiz(nP^`WA6mt3@)?Y^JfpP%Np0H=9YBZyM{yodB` z*p&-N+{W(zEf*wd?P{CQIfw?YckK!WZz?b>xxJr&k%`2w42E~|0qRN!aMCxn63J`1 zaAhht+s|GVL48gx4S}bAC-^$goDJBVAwE}Zkv25W3QTLq1!$vFa@v=L1YdCC{SvWz zJRzcPqppvn#v(X?y4@cZ`udqtqr0J&g_s*krTPc&d)|Hntoduy9m>d`Kj(yOJ#j_X zP~jGC1V9iVO@h1hleeeM4~9F)2{oPr6yG(};!`kHZ$iz}{@ z9t-ylbX4{0)F%QmHQG!YS5vxWx$x1_>3lwWS%(F}(*P(n+fY`} zCM+6?ae1Dlt4HkJ!#jo8(fCc;xro}U4k%#y4f#-G-J`ewdHUk6@-&8TzbT>3OYX6l zE;3sQK&0*u6hQ2+?WgrQf2Vg{z;^; zZ6j?jBCj+#$ZeqQ7=Ci%0loxQAYC&=r_nGq8HphE;jOB?bc??1r*JAL)8gls)5?gC zM@&rAxJaBwW_}K(wWJSD2!yN0Xf@$pFO9_tY4G^GTpR7V!4{391^O=b>6hG~KxY6@ zERbJAlH0t1)`+z#+W7d9S}Hky%&K1CKq5sL$*81Z701rh5P~mX3pl%JL~_NpDHdo7 zE)901>B!(z9=CUSjp~I1^K`Dq0ZAFqj`xXWpS^3rC>^8Z_U zF!$HJ3wGLZQo2-3blNly3V5!^dGyqiK>+#)6!hk6&J?3w0t=wispsXTtRn^_Cn_qS z0RZGL!LTCfYWXVt?i~(&CG^v!G0t@F?!8rPjBEcEiwiEm&>xZlB$SO2f}#kx&x4U z=Jf;37U*?k7}+y(y#XaT60kTx;KalOXgWSuC8pn?U~zf&E?w8l z!4QF-KED1t`g@D@G!HVp9(SP03YT2;pJ=9jj@y!KGs6X~qD98;#3=JS=>kf?;6s4M zzX773fY)1_(ckKRHa2&`yM@b-IxwHFe%wyP0SVb>&!lQ9Wx$HS<%{CGyNi+8uck)& z03}Vb1>H7WCe^3M;m}Q1GjQ#-QJPZmNEeHB%0$Q306%861kA|4AM%&-;YD!adipJ# zJNrst@P21*K^5rDiY{`?YwD-0w?xqgnweC-54d)iQns0zxg_IU@!{~_ppGm}%dI>N z-p#bc$Q{2*oT|5!-KYmd0YGb@m+}qFk#ty)|ND0pM+!8r4O}$Ld-nu*PyfMui}%Pu zy<6Cs@r85__O>zQupyKC{|D6nf64(WK?%VGPd7c-PCLrcILMxxER=uvu#F^x^(lHL zc}rgW=Yf$}h?QvHb#;9Y`CKbf zDn!Gii(b+*{HkA?1l-nHe?STN_zaAYeyJWC^`tyMvc>Yb#gTM1Tp;`V?%3erllwk= z!F$@NR9FxUDbcpc`1Y0s*5mPHEx_%3KWelLKy%dxe<3?V%UK_n?_AiA!}R7wmv)fH z{$jn?Q<;XzV|ivrg>(Y)j%BV_R<5oQ0J^gV0HH%v{S>MGqwON4fV?1J*dC8+YFdaV zr3?T-B2J7XO@m(q!nWKRC4{1#1kC-NXVdw^P--56$=}>`D@duQzMn^-i9!N^F^$TM zAz_U0RcjYRi7GF*vmc#L7Gs^9O~Ry>)whw{MGT3+7y ztS6v7bhCG`9T*e780qJxu7}B=MX@N#UH%Mj<6{v^W8q*HuG||=kh9pIdyVt+KDlp% zV>I%yN?@hy#q+7C$@$^oD9Gg7zS$A9R@=sf6jV4Rp_l6LbQ-}qEPp>A+*&qH~?KfH9>!*_EtYh2v+-K$Po zuo_fn;dW&OXcgY+3lBJ#86v*3Hp1*L;}qA{TD1UNg&0`OD|Rj1kv;n6Xhgx@MWxo( zNM%IXX-hgx_-|Me|8!T#$+P3oD^V|q^`S?K@rIC?PsCN#I4H^EJ#t=k`4fOy-;11q zPtbSU>Nj+Z`A4(aA8&&b-|f&|W4qDfaLpMQ4W##Z@qb7wL`KC_JKc-%zxSKYqJU z>i}PXr}zVaDbDXmW8&NLNLGA5z+y=?t%|Kr+n*Q2x8fTo-%gf|&w$K5Ao#5;88xiY zsz$oM&oG^v0fH%lDB@n-bTpSKAY-Vm=GPYD6L4yO_!%R2&jdh~hBo;xFnD)R?6SZe zXM>9&1bxLKZoZGFlPCBh3*&uR*quZ0@T$2VnHR`!9`{i_|6Cz zMyLM{r5`%BSK&3&Q?I#JrpCn^w$S`%;F8zGG4Pa9`o+0V)W71~j z&rp>fYkTa!)3Ph;s~w*2=;2k4HX09+hxOHmTbD5rW2s8Kn$hn#zAXTH7IDB|!FSfXn_55*H-fyhid2=vr{Mnrkihn~ literal 0 HcmV?d00001 diff --git a/app/test/goldens/goldens/linux/clide_tab_bar.png b/app/test/goldens/goldens/linux/clide_tab_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..a261387467ecdfb1632be2a07db6fe60f91baf08 GIT binary patch literal 7258 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 R3wSsPGSD^FL2Efg{tv5TW8(k- literal 0 HcmV?d00001 diff --git a/app/test/helpers/fake_ipc.dart b/app/test/helpers/fake_ipc.dart new file mode 100644 index 00000000..c30d30c9 --- /dev/null +++ b/app/test/helpers/fake_ipc.dart @@ -0,0 +1,57 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/kernel/kernel.dart'; + +/// A DaemonClient that doesn't actually open a socket. Use in tests +/// that need a connected-state observable but not a real daemon. +class FakeDaemonClient extends DaemonClient { + FakeDaemonClient({required super.log, required super.events}) + : super(socketPath: '/dev/null/fake-clide.sock'); + + bool _fakeConnected = false; + final Map Function(Map)> _stubs = + {}; + + @override + bool get isConnected => _fakeConnected; + + @override + Future start() async { + // No real socket; tests drive connection-state via [setConnected]. + } + + @override + Future stop() async { + _fakeConnected = false; + notifyListeners(); + } + + @override + Future request( + String cmd, { + Map args = const {}, + }) async { + final stub = _stubs[cmd]; + if (stub != null) return stub(args); + return IpcResponse.err( + id: '', + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: 'no stub for $cmd', + ), + ); + } + + void setConnected(bool v) { + if (_fakeConnected == v) return; + _fakeConnected = v; + notifyListeners(); + } + + void stub( + String cmd, + Future Function(Map) handler, + ) { + _stubs[cmd] = handler; + } +} diff --git a/app/test/helpers/golden_harness.dart b/app/test/helpers/golden_harness.dart new file mode 100644 index 00000000..ee5637f8 --- /dev/null +++ b/app/test/helpers/golden_harness.dart @@ -0,0 +1,22 @@ +import 'package:alchemist/alchemist.dart'; + +/// Alchemist config shared across all golden tests. +/// +/// * CI mode uses the Ahem font (shipped with Flutter's test harness) so +/// goldens render identically on every Linux runner and developer +/// machine. Any drift between platforms points to a real theme-token +/// regression, not a font-rendering fluke. +/// * Local mode keeps developer-machine fonts so you can eyeball +/// renders naturally; the `--update-goldens` workflow still produces +/// CI-valid goldens because CI runs the config below. +AlchemistConfig clideGoldenConfig({bool forceCiMode = false}) { + return AlchemistConfig( + theme: null, // we're not using Material ThemeData + platformGoldensConfig: PlatformGoldensConfig( + enabled: !forceCiMode, + ), + ciGoldensConfig: const CiGoldensConfig( + enabled: true, + ), + ); +} diff --git a/app/test/helpers/kernel_fixture.dart b/app/test/helpers/kernel_fixture.dart new file mode 100644 index 00000000..a30ef0ac --- /dev/null +++ b/app/test/helpers/kernel_fixture.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; + +import 'fake_ipc.dart'; + +/// Boots a [KernelServices] with in-memory defaults suitable for tests. +/// No real daemon, no real filesystem outside a temp dir, no asset +/// bundle — i18n catalogs are passed as literals. +class KernelFixture { + KernelFixture._( + {required this.services, required this.ipc, required this.tempDir}); + + final KernelServices services; + final FakeDaemonClient ipc; + final Directory tempDir; + + static Future create({ + List? bundledThemes, + Map>>? i18nCatalogs, + Locale? initialLocale, + Locale defaultLocale = const Locale('en', 'US'), + }) async { + final tempDir = await Directory.systemTemp.createTemp('clide_test_'); + final themes = bundledThemes ?? [_miniTheme()]; + final catalogs = i18nCatalogs ?? const {}; + FakeDaemonClient? fake; + final services = await KernelServices.boot( + appDir: tempDir, + bundledThemes: themes, + i18nLoader: InMemoryCatalogLoader(catalogs), + preloadNamespaces: catalogs.keys.toList(), + defaultLocale: defaultLocale, + initialLocale: initialLocale, + daemonClientFactory: (log, events) { + fake = FakeDaemonClient(log: log, events: events); + return fake!; + }, + autoStartDaemonClient: false, + ); + return KernelFixture._( + services: services, + ipc: fake!, + tempDir: tempDir, + ); + } + + Future dispose() async { + await services.dispose(); + if (await tempDir.exists()) { + try { + await tempDir.delete(recursive: true); + } catch (_) { + // ignore in tests; OS will reclaim + } + } + } +} + +/// A minimal bundled theme for tests that don't care about specific +/// colors — just need the pipeline to resolve. +ThemeDefinition _miniTheme() { + const palette = { + 'primary': Color(0xFF00A3D2), + 'accent': Color(0xFFFA5F8B), + 'background': Color(0xFF21262F), + 'surface': Color(0xFF393E48), + 'panel': Color(0xFF292E38), + 'foreground': Color(0xFFE2E8F5), + 'muted': Color(0xFF6A7280), + 'success': Color(0xFF00AB9A), + 'warning': Color(0xFFD08447), + 'error': Color(0xFFF06C6F), + 'info': Color(0xFF00A3D2), + }; + return const ThemeDefinition( + name: 'test', + displayName: 'Test', + dark: true, + palette: Palette(palette), + ); +} diff --git a/app/test/helpers/widget_harness.dart b/app/test/helpers/widget_harness.dart new file mode 100644 index 00000000..3d148668 --- /dev/null +++ b/app/test/helpers/widget_harness.dart @@ -0,0 +1,22 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; + +import 'kernel_fixture.dart'; + +/// Wraps a widget in the minimum tree a primitive needs to resolve +/// theme + i18n: `Directionality → ClideKernel → ClideTheme → child`. +Widget harness(KernelFixture fixture, Widget child) { + return Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: fixture.services, + child: ClideTheme( + controller: fixture.services.theme, + child: MediaQuery( + data: const MediaQueryData(), + child: child, + ), + ), + ), + ); +} diff --git a/app/test/kernel/src/commands/keybindings_test.dart b/app/test/kernel/src/commands/keybindings_test.dart new file mode 100644 index 00000000..3a4333c9 --- /dev/null +++ b/app/test/kernel/src/commands/keybindings_test.dart @@ -0,0 +1,45 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Keybinding.parse + equality', () { + test('parses single-key bindings', () { + final k = Keybinding.parse('g'); + expect(k.key, 'g'); + expect(k.modifiers, isEmpty); + }); + + test('parses modifier chains case-insensitively', () { + final a = Keybinding.parse('Ctrl+Shift+G'); + final b = Keybinding.parse('shift+ctrl+g'); + expect(a, equals(b)); + expect(a.canonical, 'ctrl+shift+g'); + }); + + test('rejects empty string', () { + expect(() => Keybinding.parse(''), throwsA(isA())); + }); + + test('canonical modifier order is deterministic', () { + final k = Keybinding.parse('alt+ctrl+shift+x'); + expect(k.modifiers, ['alt', 'ctrl', 'shift']); + }); + }); + + group('KeybindingResolver', () { + test('bind + lookup round-trips', () { + final r = KeybindingResolver(); + r.bind(Keybinding.parse('ctrl+shift+g'), 'git.commit'); + expect(r.commandFor(Keybinding.parse('ctrl+shift+g')), 'git.commit'); + expect(r.commandFor(Keybinding.parse('ctrl+g')), isNull); + }); + + test('unbind removes the mapping', () { + final r = KeybindingResolver(); + final k = Keybinding.parse('ctrl+p'); + r.bind(k, 'palette.open'); + r.unbind(k); + expect(r.commandFor(k), isNull); + }); + }); +} diff --git a/app/test/kernel/src/commands/palette_test.dart b/app/test/kernel/src/commands/palette_test.dart new file mode 100644 index 00000000..0d5c979a --- /dev/null +++ b/app/test/kernel/src/commands/palette_test.dart @@ -0,0 +1,61 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +CommandContribution _cmd(String id, {String? title}) => CommandContribution( + id: id, + command: id, + title: title, + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ); + +void main() { + group('PaletteController', () { + late CommandRegistry registry; + late PaletteController palette; + + setUp(() { + registry = CommandRegistry(); + registry.register(_cmd('git.commit', title: 'Git: Commit')); + registry.register(_cmd('git.push', title: 'Git: Push')); + registry.register(_cmd('theme.pick', title: 'Theme: Pick…')); + palette = PaletteController(registry); + }); + + test('open/close toggles isOpen and clears filter', () { + palette.open(); + palette.setFilter('git'); + expect(palette.isOpen, true); + expect(palette.filter, 'git'); + palette.close(); + expect(palette.isOpen, false); + expect(palette.filter, ''); + }); + + test('filtered empty filter returns all commands', () { + expect(palette.filtered().length, 3); + }); + + test('filter is case-insensitive against title or command', () { + palette.setFilter('git'); + expect( + palette.filtered().map((c) => c.command).toSet(), + {'git.commit', 'git.push'}, + ); + palette.setFilter('PICK'); + expect( + palette.filtered().map((c) => c.command).toSet(), + {'theme.pick'}, + ); + }); + + test('invoke closes the palette + runs the command', () async { + palette.open(); + palette.setFilter('git'); + await palette.invoke('git.commit'); + expect(palette.isOpen, false); + expect(palette.filter, ''); + }); + }); +} diff --git a/app/test/kernel/src/commands/registry_test.dart b/app/test/kernel/src/commands/registry_test.dart new file mode 100644 index 00000000..d5f7a526 --- /dev/null +++ b/app/test/kernel/src/commands/registry_test.dart @@ -0,0 +1,51 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +CommandContribution _cmd(String name, Future Function() run) => + CommandContribution( + id: name, + command: name, + title: 'cmd $name', + run: (_) => run(), + ); + +void main() { + group('CommandRegistry', () { + test('register exposes the command; all enumerates', () { + final r = CommandRegistry(); + r.register(_cmd('a', () async => IpcResponse.ok(id: '', data: const {}))); + r.register(_cmd('b', () async => IpcResponse.ok(id: '', data: const {}))); + expect(r.all.map((c) => c.command).toList(), ['a', 'b']); + expect(r.get('a'), isNotNull); + }); + + test('execute returns the handler response', () async { + final r = CommandRegistry(); + r.register( + _cmd( + 'ping', + () async => IpcResponse.ok(id: '', data: const {'pong': true}), + ), + ); + final resp = await r.execute('ping'); + expect(resp.ok, true); + expect(resp.data['pong'], true); + }); + + test('execute on unknown returns NotFound error', () async { + final r = CommandRegistry(); + final resp = await r.execute('missing'); + expect(resp.ok, false); + expect(resp.error!.code, IpcExitCode.notFound); + }); + + test('unregister removes the command', () { + final r = CommandRegistry(); + r.register(_cmd('x', () async => IpcResponse.ok(id: '', data: const {}))); + r.unregister('x'); + expect(r.get('x'), isNull); + }); + }); +} diff --git a/app/test/kernel/src/events/bus_test.dart b/app/test/kernel/src/events/bus_test.dart new file mode 100644 index 00000000..0bf08f4a --- /dev/null +++ b/app/test/kernel/src/events/bus_test.dart @@ -0,0 +1,69 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EventBus', () { + late EventBus bus; + + setUp(() => bus = EventBus()); + tearDown(() => bus.dispose()); + + test('emit delivers to stream subscribers', () async { + final events = []; + final sub = bus.stream.listen(events.add); + bus.emit(const ThemeChanged(themeName: 'summer-night')); + await Future.delayed(Duration.zero); + expect(events, hasLength(1)); + expect(events.first.event, isA()); + await sub.cancel(); + }); + + test('on() filters by runtime type', () async { + final themes = []; + final extensions = []; + final s1 = bus.on().listen(themes.add); + final s2 = bus.on().listen(extensions.add); + bus.emit(const ThemeChanged(themeName: 'a')); + bus.emit(const ExtensionActivated(id: 'builtin.git')); + bus.emit(const ThemeChanged(themeName: 'b')); + await Future.delayed(Duration.zero); + expect(themes.map((e) => e.themeName), ['a', 'b']); + expect(extensions.map((e) => e.id), ['builtin.git']); + await s1.cancel(); + await s2.cancel(); + }); + + test('broadcasts to multiple listeners independently', () async { + final a = []; + final b = []; + final s1 = bus.stream.listen((e) => a.add(e.event)); + final s2 = bus.stream.listen((e) => b.add(e.event)); + bus.emit(const ThemeChanged(themeName: 'x')); + await Future.delayed(Duration.zero); + expect(a, hasLength(1)); + expect(b, hasLength(1)); + await s1.cancel(); + await s2.cancel(); + }); + + test('emit after dispose is a silent no-op', () async { + await bus.dispose(); + // must not throw + bus.emit(const ThemeChanged(themeName: 'nope')); + }); + + test('envelope stamps a timestamp', () async { + final capture = []; + final sub = bus.stream.listen(capture.add); + final before = DateTime.now().toUtc(); + bus.emit(const ThemeChanged(themeName: 'n')); + await Future.delayed(Duration.zero); + final after = DateTime.now().toUtc(); + expect(capture, hasLength(1)); + final ts = capture.first.timestamp; + expect(ts.isAfter(before) || ts.isAtSameMomentAs(before), isTrue); + expect(ts.isBefore(after) || ts.isAtSameMomentAs(after), isTrue); + await sub.cancel(); + }); + }); +} diff --git a/app/test/kernel/src/extensions_manager_test.dart b/app/test/kernel/src/extensions_manager_test.dart new file mode 100644 index 00000000..22197697 --- /dev/null +++ b/app/test/kernel/src/extensions_manager_test.dart @@ -0,0 +1,192 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +/// Minimal no-op extension used as a test actor. +class _Ext extends ClideExtension { + _Ext({ + required this.id, + this.dependsOn = const [], + this.contributions = const [], + this.onActivate, + this.onDeactivate, + }); + + @override + final String id; + @override + String get title => id; + @override + String get version => '0.0.0-test'; + @override + final List dependsOn; + @override + final List contributions; + + final Future Function(ClideExtensionContext ctx)? onActivate; + final Future Function()? onDeactivate; + + @override + Future activate(ClideExtensionContext ctx) async { + if (onActivate != null) await onActivate!(ctx); + } + + @override + Future deactivate() async { + if (onDeactivate != null) await onDeactivate!(); + } +} + +void main() { + group('ExtensionManager', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + }); + + tearDown(() async { + await f.dispose(); + }); + + test('register + activateAll runs extensions in dep order', () async { + final order = []; + f.services.extensions + ..register(_Ext( + id: 'a', + onActivate: (_) async => order.add('a'), + )) + ..register(_Ext( + id: 'b', + dependsOn: const ['a'], + onActivate: (_) async => order.add('b'), + )) + ..register(_Ext( + id: 'c', + dependsOn: const ['b'], + onActivate: (_) async => order.add('c'), + )); + await f.services.extensions.activateAll(); + expect(order, ['a', 'b', 'c']); + }); + + test('missing dep skips the dependent with a warning', () async { + final order = []; + f.services.extensions.register(_Ext( + id: 'needs-missing', + dependsOn: const ['does.not.exist'], + onActivate: (_) async => order.add('needs-missing'), + )); + await f.services.extensions.activateAll(); + expect(order, isEmpty); + expect(f.services.extensions.isActivated('needs-missing'), false); + }); + + test('contribution points wire into panel registry on activate', () async { + f.services.extensions.register(_Ext( + id: 'with-tab', + contributions: [ + TabContribution( + id: 'with-tab.view', + slot: Slots.workspace, + title: 'T', + build: (_) => const SizedBox.shrink(), + ), + ], + )); + await f.services.extensions.activateAll(); + expect( + f.services.panels.tabsFor(Slots.workspace).map((t) => t.id), + ['with-tab.view'], + ); + }); + + test('deactivate removes contributions from the registry', () async { + f.services.extensions.register(_Ext( + id: 'ephemeral', + contributions: [ + TabContribution( + id: 'ephemeral.view', + slot: Slots.workspace, + title: 'T', + build: (_) => const SizedBox.shrink(), + ), + ], + )); + await f.services.extensions.activateAll(); + expect(f.services.panels.tabsFor(Slots.workspace), hasLength(1)); + await f.services.extensions.deactivate('ephemeral'); + expect(f.services.panels.tabsFor(Slots.workspace), isEmpty); + }); + + test('CommandContribution registers + default binding is bound', () async { + f.services.extensions.register(_Ext( + id: 'has-cmd', + contributions: [ + CommandContribution( + id: 'c', + command: 'test.cmd', + defaultBinding: 'ctrl+alt+k', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ), + ], + )); + await f.services.extensions.activateAll(); + expect(f.services.commands.get('test.cmd'), isNotNull); + expect( + f.services.keybindings.commandFor(Keybinding.parse('ctrl+alt+k')), + 'test.cmd', + ); + }); + + test('setEnabled=false deactivates; =true reactivates', () async { + final order = []; + f.services.extensions.register(_Ext( + id: 'toggle', + onActivate: (_) async => order.add('on'), + onDeactivate: () async => order.add('off'), + )); + await f.services.extensions.activateAll(); + expect(order, ['on']); + await f.services.extensions.setEnabled('toggle', false); + expect(order, ['on', 'off']); + await f.services.extensions.setEnabled('toggle', true); + expect(order, ['on', 'off', 'on']); + }); + + test('dependency cycle warns, does not infinite-loop', () async { + f.services.extensions + ..register(_Ext(id: 'x', dependsOn: const ['y'])) + ..register(_Ext(id: 'y', dependsOn: const ['x'])); + // Should not throw; both should fail to activate because of + // unsatisfied deps. + await f.services.extensions.activateAll(); + expect(f.services.extensions.isActivated('x'), false); + expect(f.services.extensions.isActivated('y'), false); + }); + + test('emits ExtensionActivated / ExtensionDeactivated events', () async { + final activated = []; + final deactivated = []; + final s1 = f.services.events + .on() + .listen((e) => activated.add(e.id)); + final s2 = f.services.events + .on() + .listen((e) => deactivated.add(e.id)); + f.services.extensions.register(_Ext(id: 'e')); + await f.services.extensions.activateAll(); + await Future.delayed(Duration.zero); + await f.services.extensions.deactivate('e'); + await Future.delayed(Duration.zero); + expect(activated, ['e']); + expect(deactivated, ['e']); + await s1.cancel(); + await s2.cancel(); + }); + }); +} diff --git a/app/test/kernel/src/facade_test.dart b/app/test/kernel/src/facade_test.dart new file mode 100644 index 00000000..260d25f6 --- /dev/null +++ b/app/test/kernel/src/facade_test.dart @@ -0,0 +1,63 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + group('KernelServices facade', () { + test('boot wires every service and they are non-null', () async { + final f = await KernelFixture.create(); + addTearDown(f.dispose); + final s = f.services; + expect(s.log, isNotNull); + expect(s.events, isNotNull); + expect(s.settings, isNotNull); + expect(s.ipc, isNotNull); + expect(s.theme, isNotNull); + expect(s.i18n, isNotNull); + expect(s.panels, isNotNull); + expect(s.arrangement, isNotNull); + expect(s.commands, isNotNull); + expect(s.palette, isNotNull); + expect(s.keybindings, isNotNull); + expect(s.clipboard, isNotNull); + expect(s.files, isNotNull); + expect(s.notify, isNotNull); + expect(s.dialog, isNotNull); + expect(s.tray, isNotNull); + expect(s.secrets, isNotNull); + expect(s.os, isNotNull); + expect(s.net, isNotNull); + expect(s.focus, isNotNull); + expect(s.project, isNotNull); + expect(s.extensions, isNotNull); + }); + + test('i18n is preloaded for the namespaces passed to boot', () async { + final f = await KernelFixture.create( + i18nCatalogs: { + 'builtin.welcome': { + const Locale('en', 'US'): { + 'title': const {'translation': 'clide'}, + }, + }, + }, + ); + addTearDown(f.dispose); + expect( + f.services.i18n.string( + 'title', + namespace: 'builtin.welcome', + placeholder: '-', + ), + 'clide', + ); + }); + + test('dispose shuts down IPC + notifiers without throwing', () async { + final f = await KernelFixture.create(); + await f.dispose(); + }); + }); +} diff --git a/app/test/kernel/src/i18n/i18n_test.dart b/app/test/kernel/src/i18n/i18n_test.dart new file mode 100644 index 00000000..29ab20ca --- /dev/null +++ b/app/test/kernel/src/i18n/i18n_test.dart @@ -0,0 +1,228 @@ +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Fills fframe's test gap — every path through the lookup matrix is +/// exercised here. +void main() { + group('I18n lookup & fallback', () { + late Logger log; + + setUp(() { + log = Logger(minLevel: LogLevel.error); // silence warnings in tests + }); + + I18n build({ + required Map>> catalogs, + Locale initial = const Locale('en', 'US'), + Locale defaultLocale = const Locale('en', 'US'), + }) { + final loader = InMemoryCatalogLoader(catalogs); + final i = I18n( + loader: loader, + log: log, + defaultLocale: defaultLocale, + initialLocale: initial, + availableLocales: const [Locale('en', 'US'), Locale('nl', 'NL')], + ); + return i; + } + + test('missing key with placeholder returns placeholder', () async { + final i = build(catalogs: const {}); + await i.ensureNamespaceLoaded('builtin.x'); + expect( + i.string('missing', namespace: 'builtin.x', placeholder: 'fallback'), + 'fallback', + ); + }); + + test('missing key with null placeholder returns the key', () async { + final i = build(catalogs: const {}); + await i.ensureNamespaceLoaded('builtin.x'); + expect(i.string('foo.bar', namespace: 'builtin.x'), 'foo.bar'); + }); + + test('unknown namespace still returns placeholder (no crash)', () { + final i = build(catalogs: const {}); + expect( + i.string('k', namespace: 'not.registered', placeholder: 'fb'), + 'fb', + ); + }); + + test('exact locale hit beats fallback', () async { + final i = build(catalogs: { + 'builtin.x': { + const Locale('en', 'US'): { + 'greet': {'translation': 'Hello'}, + }, + const Locale('en'): { + 'greet': {'translation': 'Hi'}, + }, + }, + }); + await i.ensureNamespaceLoaded('builtin.x'); + expect( + i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), + 'Hello', + ); + }); + + test('language-only locale hit falls through from exact', () async { + final i = build(catalogs: { + 'builtin.x': { + const Locale('nl'): { + 'greet': {'translation': 'Hoi'}, + }, + }, + }, initial: const Locale('nl', 'NL')); + await i.ensureNamespaceLoaded('builtin.x'); + expect( + i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), + 'Hoi', + ); + }); + + test('falls through to default-locale when current locale is empty', + () async { + final i = build(catalogs: { + 'builtin.x': { + const Locale('en', 'US'): { + 'greet': {'translation': 'Hello'}, + }, + }, + }, initial: const Locale('nl', 'NL')); + await i.ensureNamespaceLoaded('builtin.x'); + expect( + i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), + 'Hello', + ); + }); + + test('interpolation replaces all replacers; missing ones silent', () async { + final i = build(catalogs: { + 'builtin.x': { + const Locale('en', 'US'): { + 'welcome': {'translation': 'Hi {name} at {path}'}, + }, + }, + }); + await i.ensureNamespaceLoaded('builtin.x'); + expect( + i.interpolated( + 'welcome', + namespace: 'builtin.x', + placeholder: 'Hi {name} at {path}', + replacers: const [ + I18nReplacer(from: '{name}', replace: 'Claude'), + // {path} deliberately absent + ], + ), + 'Hi Claude at {path}', + ); + }); + + test('namespace isolation — same key, different values', () async { + final i = build(catalogs: { + 'a': { + const Locale('en', 'US'): { + 'k': {'translation': 'A'}, + }, + }, + 'b': { + const Locale('en', 'US'): { + 'k': {'translation': 'B'}, + }, + }, + }); + await i.ensureNamespaceLoaded('a'); + await i.ensureNamespaceLoaded('b'); + expect(i.string('k', namespace: 'a', placeholder: '-'), 'A'); + expect(i.string('k', namespace: 'b', placeholder: '-'), 'B'); + }); + + test('setLocale refreshes cached namespaces and notifies listeners', + () async { + final i = build(catalogs: { + 'x': { + const Locale('en', 'US'): { + 'k': {'translation': 'Hello'}, + }, + const Locale('nl'): { + 'k': {'translation': 'Hallo'}, + }, + }, + }); + await i.ensureNamespaceLoaded('x'); + var notified = 0; + i.addListener(() => notified++); + await i.setLocale(const Locale('nl')); + expect(notified, greaterThanOrEqualTo(1)); + expect(i.string('k', namespace: 'x', placeholder: '-'), 'Hallo'); + }); + + test('registerCatalog merges third-party catalog', () async { + final i = build(catalogs: const {}); + i.registerCatalog('ext.linear', const Locale('en', 'US'), const { + 'issue.title': {'translation': 'Issues'}, + }); + expect( + i.string('issue.title', namespace: 'ext.linear', placeholder: '-'), + 'Issues', + ); + }); + + test('unregisterCatalog forgets a namespace', () async { + final i = build(catalogs: const {}); + i.registerCatalog('ext.x', const Locale('en', 'US'), const { + 'k': {'translation': 'v'}, + }); + expect(i.string('k', namespace: 'ext.x', placeholder: '-'), 'v'); + i.unregisterCatalog('ext.x'); + expect(i.string('k', namespace: 'ext.x', placeholder: '-'), '-'); + }); + + test('plain-string shape (no nested translation) is accepted', () async { + // Forward-compat: if a catalog later switches to `"k": "v"` + // instead of `"k": {"translation": "v"}`, lookup still works. + final i = build(catalogs: { + 'x': { + const Locale('en', 'US'): {'k': 'direct'}, + }, + }); + await i.ensureNamespaceLoaded('x'); + expect(i.string('k', namespace: 'x', placeholder: '-'), 'direct'); + }); + }); + + group('FallbackChain.resolve', () { + test('ordering: exact, lang, default, default-lang', () { + final chain = const FallbackChain( + current: Locale('nl', 'NL'), + defaultLocale: Locale('en', 'US'), + ).resolve(); + expect(chain.map((l) => l.toString()).toList(), [ + 'nl_NL', + 'nl', + 'en_US', + 'en', + ]); + }); + + test('deduplicates when current == default', () { + final chain = const FallbackChain( + current: Locale('en', 'US'), + defaultLocale: Locale('en', 'US'), + ).resolve(); + expect(chain, ['en_US', 'en'].map((_) => isA())); + expect(chain.length, 2); + }); + + test('filenameSuffix lowercases + omits empty country', () { + expect(FallbackChain.filenameSuffix(const Locale('en', 'US')), 'en_us'); + expect(FallbackChain.filenameSuffix(const Locale('en')), 'en'); + }); + }); +} diff --git a/app/test/kernel/src/log_test.dart b/app/test/kernel/src/log_test.dart new file mode 100644 index 00000000..cd37c788 --- /dev/null +++ b/app/test/kernel/src/log_test.dart @@ -0,0 +1,71 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Logger', () { + test('respects minLevel — lower-level messages drop silently', () { + final out = []; + final log = Logger( + minLevel: LogLevel.warn, + sinks: [out.add], + ); + log.debug('s', 'dropped'); + log.info('s', 'dropped'); + log.warn('s', 'kept'); + log.error('s', 'kept'); + expect(out.map((r) => r.message), ['kept', 'kept']); + }); + + test('minLevel is mutable post-construction', () { + final out = []; + final log = Logger(minLevel: LogLevel.error, sinks: [out.add]); + log.info('s', 'dropped'); + log.minLevel = LogLevel.info; + log.info('s', 'kept'); + expect(out.map((r) => r.message), ['kept']); + }); + + test('error + stack trace propagate to sinks', () { + final out = []; + final log = Logger(minLevel: LogLevel.debug, sinks: [out.add]); + final st = StackTrace.current; + log.error('s', 'boom', error: 'e', stackTrace: st); + expect(out, hasLength(1)); + expect(out.first.level, LogLevel.error); + expect(out.first.error, 'e'); + expect(out.first.stackTrace, st); + }); + + test('broken sink does not kill the logger', () { + final good = []; + final log = Logger(minLevel: LogLevel.info, sinks: [ + (_) => throw StateError('bad sink'), + good.add, + ]); + log.info('s', 'still delivered'); + expect(good, hasLength(1)); + }); + + test('records stream for subscribers', () async { + final log = Logger(minLevel: LogLevel.info); + final out = []; + final sub = log.records.listen(out.add); + log.info('s', 'm1'); + log.info('s', 'm2'); + await Future.delayed(Duration.zero); + expect(out.map((r) => r.message), ['m1', 'm2']); + await sub.cancel(); + await log.dispose(); + }); + + test('addSink appends without replacing', () { + final a = []; + final b = []; + final log = Logger(minLevel: LogLevel.info, sinks: [a.add]); + log.addSink(b.add); + log.info('s', 'hello'); + expect(a, hasLength(1)); + expect(b, hasLength(1)); + }); + }); +} diff --git a/app/test/kernel/src/panels/arrangement_test.dart b/app/test/kernel/src/panels/arrangement_test.dart new file mode 100644 index 00000000..69d6f5c0 --- /dev/null +++ b/app/test/kernel/src/panels/arrangement_test.dart @@ -0,0 +1,50 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('LayoutArrangement', () { + test('applyPreset sets position + size + visibility per slot', () { + final a = LayoutArrangement(); + a.applyPreset(classicPreset()); + expect(a.positionOf(Slots.sidebar), SlotPosition.left); + expect(a.sizeOf(Slots.sidebar), 240); + expect(a.minSizeOf(Slots.sidebar), 180); + expect(a.maxSizeOf(Slots.sidebar), 400); + expect(a.isVisible(Slots.workspace), true); + }); + + test('setSize clamps to min/max', () { + final a = LayoutArrangement()..applyPreset(classicPreset()); + a.setSize(Slots.sidebar, 50); // below min 180 + expect(a.sizeOf(Slots.sidebar), 180); + a.setSize(Slots.sidebar, 10000); // above max 400 + expect(a.sizeOf(Slots.sidebar), 400); + }); + + test('setSize notifies listeners only when changing', () { + final a = LayoutArrangement()..applyPreset(classicPreset()); + var count = 0; + a.addListener(() => count++); + a.setSize(Slots.sidebar, 240); // already 240, no change + expect(count, 0); + a.setSize(Slots.sidebar, 260); + expect(count, 1); + }); + + test('setVisible flips the flag', () { + final a = LayoutArrangement()..applyPreset(classicPreset()); + expect(a.isVisible(Slots.sidebar), true); + a.setVisible(Slots.sidebar, false); + expect(a.isVisible(Slots.sidebar), false); + }); + + test('registerSlotsInto populates a PanelRegistry', () { + final a = LayoutArrangement(); + final r = PanelRegistry(); + final preset = classicPreset(); + a.registerSlotsInto(r, preset); + expect(r.definitionFor(Slots.sidebar), isNotNull); + expect(r.definitionFor(Slots.statusbar), isNotNull); + }); + }); +} diff --git a/app/test/kernel/src/panels/registry_test.dart b/app/test/kernel/src/panels/registry_test.dart new file mode 100644 index 00000000..cc1ba506 --- /dev/null +++ b/app/test/kernel/src/panels/registry_test.dart @@ -0,0 +1,76 @@ +import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +TabContribution _tab({ + required String id, + required SlotId slot, + int priority = 0, +}) => + TabContribution( + id: id, + slot: slot, + title: id, + priority: priority, + build: (_) => const SizedBox.shrink(), + ); + +void main() { + group('PanelRegistry', () { + late PanelRegistry r; + + setUp(() => r = PanelRegistry()); + + test('registerSlot creates an empty mount list', () { + r.registerSlot(const SlotDefinition( + id: Slots.sidebar, + position: SlotPosition.left, + )); + expect(r.definitionFor(Slots.sidebar)!.position, SlotPosition.left); + expect(r.contributionsFor(Slots.sidebar), isEmpty); + }); + + test('contribute appends to the slot and orders by priority', () { + r.contribute(_tab(id: 'a', slot: Slots.sidebar, priority: 10)); + r.contribute(_tab(id: 'b', slot: Slots.sidebar, priority: -5)); + r.contribute(_tab(id: 'c', slot: Slots.sidebar, priority: 0)); + expect(r.tabsFor(Slots.sidebar).map((t) => t.id), ['b', 'c', 'a']); + }); + + test('first tab contribution becomes the active tab', () { + r.contribute(_tab(id: 'a', slot: Slots.sidebar)); + expect(r.activeTabIn(Slots.sidebar), 'a'); + r.contribute(_tab(id: 'b', slot: Slots.sidebar)); + expect(r.activeTabIn(Slots.sidebar), 'a'); + }); + + test('uncontribute removes by id and reassigns active tab', () { + r.contribute(_tab(id: 'a', slot: Slots.sidebar)); + r.contribute(_tab(id: 'b', slot: Slots.sidebar)); + expect(r.activeTabIn(Slots.sidebar), 'a'); + r.uncontribute('a'); + expect(r.tabsFor(Slots.sidebar).map((t) => t.id), ['b']); + expect(r.activeTabIn(Slots.sidebar), 'b'); + }); + + test('activateTab notifies listeners', () { + r.contribute(_tab(id: 'a', slot: Slots.sidebar)); + r.contribute(_tab(id: 'b', slot: Slots.sidebar)); + var count = 0; + r.addListener(() => count++); + r.activateTab(Slots.sidebar, 'b'); + expect(r.activeTabIn(Slots.sidebar), 'b'); + expect(count, 1); + }); + + test('contributing a non-slot contribution is a no-op for slots', () { + r.contribute(CommandContribution( + id: 'c', + command: 'c', + run: (_) async => throw UnimplementedError(), + )); + expect(r.tabsFor(Slots.sidebar), isEmpty); + }); + }); +} diff --git a/app/test/kernel/src/settings_test.dart b/app/test/kernel/src/settings_test.dart new file mode 100644 index 00000000..ab6dae98 --- /dev/null +++ b/app/test/kernel/src/settings_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SettingsStore', () { + late Directory tmp; + late SettingsStore store; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('clide_settings_'); + store = SettingsStore(appDir: tmp); + await store.load(); + }); + + tearDown(() async { + store.dispose(); + if (await tmp.exists()) { + try { + await tmp.delete(recursive: true); + } catch (_) {} + } + }); + + test('scope key validation — rejects non-standard prefixes', () async { + expect( + () => store.get('nothing.here'), + throwsA(isA()), + ); + expect( + () => store.set('notascope.key', 'v'), + throwsA(isA()), + ); + }); + + test('app.* scope round-trips via YAML on disk', () async { + await store.set('app.theme.current', 'summer-night'); + expect(store.get('app.theme.current'), 'summer-night'); + final loaded = SettingsStore(appDir: tmp); + await loaded.load(); + expect(loaded.get('app.theme.current'), 'summer-night'); + loaded.dispose(); + }); + + test('app.* scope supports bool + int + list', () async { + await store.set('app.extensions.git.enabled', false); + await store.set('app.layout.width', 240); + await store.set>('app.recent', const ['/a', '/b']); + final loaded = SettingsStore(appDir: tmp); + await loaded.load(); + expect(loaded.get('app.extensions.git.enabled'), false); + expect(loaded.get('app.layout.width'), 240); + expect(loaded.get>('app.recent'), ['/a', '/b']); + loaded.dispose(); + }); + + test('setting a project.* key without an open project throws', () async { + expect( + () => store.set('project.thing', 'x'), + throwsA(isA()), + ); + }); + + test('project scope is isolated from app scope', () async { + final projectDir = await Directory.systemTemp.createTemp('clide_proj_'); + try { + await store.setProjectDir(projectDir); + await store.set('app.global', 'A'); + await store.set('project.scoped', 'P'); + expect(store.get('app.global'), 'A'); + expect(store.get('project.scoped'), 'P'); + // Reload project dir (simulate reopening) and confirm app values + // don't leak into project store. + await store.setProjectDir(null); + expect(store.get('app.global'), 'A'); + expect(store.get('project.scoped'), isNull); + await store.setProjectDir(projectDir); + expect(store.get('project.scoped'), 'P'); + } finally { + try { + await projectDir.delete(recursive: true); + } catch (_) {} + } + }); + + test('notifyListeners fires on set and load', () async { + var count = 0; + store.addListener(() => count++); + await store.set('app.k', 'v'); + expect(count, greaterThanOrEqualTo(1)); + }); + }); +} diff --git a/app/test/kernel/src/theme/controller_test.dart b/app/test/kernel/src/theme/controller_test.dart new file mode 100644 index 00000000..55527e14 --- /dev/null +++ b/app/test/kernel/src/theme/controller_test.dart @@ -0,0 +1,89 @@ +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ThemeDefinition _def(String name, Color primary) => ThemeDefinition( + name: name, + displayName: name, + dark: true, + palette: Palette({ + 'primary': primary, + 'accent': primary, + 'background': const Color(0xFF000000), + 'surface': const Color(0xFF111111), + 'panel': const Color(0xFF222222), + 'foreground': const Color(0xFFFFFFFF), + 'success': const Color(0xFF00FF00), + 'warning': const Color(0xFFFFFF00), + 'error': const Color(0xFFFF0000), + }), + ); + +void main() { + group('ThemeController', () { + test('starts on first bundled theme', () { + final c = ThemeController(bundled: [ + _def('a', const Color(0xFF111111)), + _def('b', const Color(0xFF222222)), + ]); + expect(c.currentName, 'a'); + }); + + test('honors initialName when present', () { + final c = ThemeController( + bundled: [ + _def('a', const Color(0xFF000000)), + _def('b', const Color(0xFF999999)) + ], + initialName: 'b', + ); + expect(c.currentName, 'b'); + }); + + test('silently falls back to first when initialName is unknown', () { + final c = ThemeController( + bundled: [_def('a', const Color(0xFF000000))], + initialName: 'missing', + ); + expect(c.currentName, 'a'); + }); + + test('select changes current + notifies listeners', () { + final c = ThemeController(bundled: [ + _def('a', const Color(0xFF000000)), + _def('b', const Color(0xFF333333)), + ]); + var count = 0; + c.addListener(() => count++); + c.select('b'); + expect(c.currentName, 'b'); + expect(count, 1); + }); + + test('select on same theme is a no-op', () { + final c = ThemeController(bundled: [_def('a', const Color(0xFF000000))]); + var count = 0; + c.addListener(() => count++); + c.select('a'); + expect(count, 0); + }); + + test('select throws on unknown name', () { + final c = ThemeController(bundled: [_def('a', const Color(0xFF000000))]); + expect(() => c.select('nope'), throwsA(isA())); + }); + + test('registerTheme adds a new theme and rebuilds current if matching', () { + final c = ThemeController(bundled: [_def('a', const Color(0xFFAAAAAA))]); + expect(c.available.length, 1); + c.registerTheme(_def('b', const Color(0xFFBBBBBB))); + expect(c.available.length, 2); + // re-register 'a' with a different palette — current rebuilds + var count = 0; + c.addListener(() => count++); + c.registerTheme(_def('a', const Color(0xFF123456))); + expect(count, 1); + }); + }); +} diff --git a/app/test/kernel/src/theme/loader_test.dart b/app/test/kernel/src/theme/loader_test.dart new file mode 100644 index 00000000..bce178ae --- /dev/null +++ b/app/test/kernel/src/theme/loader_test.dart @@ -0,0 +1,80 @@ +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const loader = ThemeLoader(); + + group('ThemeLoader.fromYamlString', () { + test('parses palette-only theme', () { + final def = loader.fromYamlString(''' +name: summer-night +display_name: Summer Night +dark: true +palette: + primary: "#00a3d2" + background: "#21262F" + foreground: "#E2E8F5" +'''); + expect(def.name, 'summer-night'); + expect(def.displayName, 'Summer Night'); + expect(def.dark, true); + expect(def.palette.lookup('primary'), const Color(0xFF00A3D2)); + expect(def.semanticOverride, isNull); + expect(def.surfaceOverride, isNull); + }); + + test('captures semantic overrides as palette-resolved colors', () { + final def = loader.fromYamlString(''' +name: t +palette: + red: "#FF0000" + blue: "#0000FF" +semantic: + mainchrome: red + focus: "#123456" +'''); + expect( + def.semanticOverride!.lookup('mainchrome'), const Color(0xFFFF0000)); + expect(def.semanticOverride!.lookup('focus'), const Color(0xFF123456)); + }); + + test('captures surface overrides as ref strings', () { + final def = loader.fromYamlString(''' +name: t +palette: + red: "#FF0000" +surface: + panel.background: "semantic.mainchrome" + panel.border: red +'''); + expect(def.surfaceOverride, { + 'panel.background': 'semantic.mainchrome', + 'panel.border': 'red', + }); + }); + + test('throws when name is missing and no fallback', () { + expect( + () => loader.fromYamlString('palette: { fg: "#fff" }'), + throwsA(isA()), + ); + }); + + test('throws when palette is missing', () { + expect( + () => loader.fromYamlString('name: t'), + throwsA(isA()), + ); + }); + + test('fallback name is used when name is absent', () { + final def = loader.fromYamlString( + 'palette: { fg: "#fff" }', + fallbackName: 'inferred', + ); + expect(def.name, 'inferred'); + }); + }); +} diff --git a/app/test/kernel/src/theme/resolver_test.dart b/app/test/kernel/src/theme/resolver_test.dart new file mode 100644 index 00000000..2f297d6d --- /dev/null +++ b/app/test/kernel/src/theme/resolver_test.dart @@ -0,0 +1,128 @@ +import 'dart:ui'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const resolver = ThemeResolver(); + + Palette paletteOf(Map colors) => Palette(colors); + + group('ThemeResolver default fallbacks', () { + test('palette-only summer-night shape resolves every token', () { + final tokens = resolver.resolve( + palette: paletteOf(const { + 'primary': Color(0xFF00A3D2), + 'accent': Color(0xFFFA5F8B), + 'background': Color(0xFF21262F), + 'surface': Color(0xFF393E48), + 'panel': Color(0xFF292E38), + 'foreground': Color(0xFFE2E8F5), + 'muted': Color(0xFF6A7280), + 'success': Color(0xFF00AB9A), + 'warning': Color(0xFFD08447), + 'error': Color(0xFFF06C6F), + 'info': Color(0xFF00A3D2), + }), + ); + expect(tokens.globalBackground, const Color(0xFF21262F)); + expect(tokens.globalForeground, const Color(0xFFE2E8F5)); + expect(tokens.panelBackground, const Color(0xFF292E38)); + expect(tokens.sidebarItemSelected, const Color(0xFF00A3D2)); + expect(tokens.statusSuccess, const Color(0xFF00AB9A)); + expect(tokens.statusError, const Color(0xFFF06C6F)); + expect(tokens.globalTextMuted, const Color(0xFF6A7280)); + }); + + test('missing "muted" falls back to foreground', () { + final tokens = resolver.resolve( + palette: paletteOf(const { + 'primary': Color(0xFF111111), + 'accent': Color(0xFF222222), + 'background': Color(0xFF333333), + 'surface': Color(0xFF444444), + 'panel': Color(0xFF555555), + 'foreground': Color(0xFFEEEEEE), + 'success': Color(0xFF008800), + 'warning': Color(0xFFFF8800), + 'error': Color(0xFFFF0000), + }), + ); + expect(tokens.globalTextMuted, const Color(0xFFEEEEEE)); + }); + }); + + group('ThemeResolver overrides', () { + test('surface override wins over default', () { + final tokens = resolver.resolve( + palette: paletteOf(const { + 'primary': Color(0xFF111111), + 'accent': Color(0xFF222222), + 'background': Color(0xFF333333), + 'surface': Color(0xFF444444), + 'panel': Color(0xFF555555), + 'foreground': Color(0xFFFFFFFF), + 'success': Color(0xFF008800), + 'warning': Color(0xFFFF8800), + 'error': Color(0xFFFF0000), + }), + surfaceOverride: const { + 'panel.background': '#00FF00', + }, + ); + expect(tokens.panelBackground, const Color(0xFF00FF00)); + }); + + test('semantic override propagates to surface tokens using it', () { + final tokens = resolver.resolve( + palette: paletteOf(const { + 'primary': Color(0xFF111111), + 'accent': Color(0xFF222222), + 'background': Color(0xFF333333), + 'surface': Color(0xFF444444), + 'panel': Color(0xFF555555), + 'foreground': Color(0xFFFFFFFF), + 'success': Color(0xFF008800), + 'warning': Color(0xFFFF8800), + 'error': Color(0xFFFF0000), + 'teal': Color(0xFF007777), + }), + semanticOverride: const SemanticRoles({ + 'focus': Color(0xFF007777), + }), + ); + // sidebarItemSelected defaults to semantic.focus — expect override. + expect(tokens.sidebarItemSelected, const Color(0xFF007777)); + }); + + test('extensionOverride populates extensionTokens map', () { + final tokens = resolver.resolve( + palette: paletteOf(const { + 'primary': Color(0xFF111111), + 'background': Color(0xFF333333), + 'surface': Color(0xFF444444), + 'panel': Color(0xFF555555), + 'foreground': Color(0xFFFFFFFF), + }), + extensionOverride: const { + 'ext.sqlite.table.background': '#ABCDEF', + }, + ); + expect(tokens.extensionTokens['ext.sqlite.table.background'], + const Color(0xFFABCDEF)); + }); + }); + + group('Palette.parseHex', () { + test('parses 6-digit hex as opaque', () { + expect(Palette.parseHex('#112233'), const Color(0xFF112233)); + }); + test('parses 8-digit hex with alpha', () { + expect(Palette.parseHex('#80112233'), const Color(0x80112233)); + }); + test('returns null on invalid', () { + expect(Palette.parseHex('nope'), isNull); + expect(Palette.parseHex('#12'), isNull); + }); + }); +} diff --git a/app/test/widgets/src/clide_button_test.dart b/app/test/widgets/src/clide_button_test.dart new file mode 100644 index 00000000..146981c9 --- /dev/null +++ b/app/test/widgets/src/clide_button_test.dart @@ -0,0 +1,94 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideButton', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + }); + + tearDown(() async { + await f.dispose(); + }); + + testWidgets('renders the label text', (tester) async { + await tester.pumpWidget( + harness(f, const ClideButton(label: 'Save', onPressed: null)), + ); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('emits a Semantics node with button: true + label', + (tester) async { + await tester.pumpWidget( + harness(f, ClideButton(label: 'Commit', onPressed: () {})), + ); + final semantics = tester.getSemantics(find.byType(ClideButton)); + expect(semantics.label, 'Commit'); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + reason: 'enabled button must expose tap action', + ); + }); + + testWidgets('semanticLabel overrides the visible label for a11y', + (tester) async { + await tester.pumpWidget( + harness( + f, + ClideButton( + label: 'Save', + semanticLabel: 'Save document', + onPressed: () {}, + ), + ), + ); + final semantics = tester.getSemantics(find.byType(ClideButton)); + expect(semantics.label, 'Save document'); + }); + + testWidgets('semanticHint propagates to the Semantics node', + (tester) async { + await tester.pumpWidget( + harness( + f, + ClideButton( + label: 'Pick', + semanticHint: 'Select a theme', + onPressed: () {}, + ), + ), + ); + final semantics = tester.getSemantics(find.byType(ClideButton)); + expect(semantics.hint, 'Select a theme'); + }); + + testWidgets('disabled button drops the tap action', (tester) async { + await tester.pumpWidget( + harness(f, const ClideButton(label: 'Nope', onPressed: null)), + ); + final semantics = tester.getSemantics(find.byType(ClideButton)); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + }); + + testWidgets('tap invokes onPressed', (tester) async { + var pressed = 0; + await tester.pumpWidget( + harness( + f, + ClideButton(label: 'Go', onPressed: () => pressed++), + ), + ); + await tester.tap(find.byType(ClideButton)); + expect(pressed, 1); + }); + }); +} diff --git a/app/test/widgets/src/clide_divider_test.dart b/app/test/widgets/src/clide_divider_test.dart new file mode 100644 index 00000000..19f673e4 --- /dev/null +++ b/app/test/widgets/src/clide_divider_test.dart @@ -0,0 +1,31 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideDivider', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders a 1px horizontal container by default', + (tester) async { + await tester.pumpWidget(harness(f, const ClideDivider())); + final c = tester.widget(find.byType(Container)); + expect(c.constraints?.maxHeight, 1.0); + expect(c.color, f.services.theme.current.surface.dividerColor); + }); + + testWidgets('vertical axis yields a width-constrained container', + (tester) async { + await tester.pumpWidget( + harness(f, const ClideDivider(axis: Axis.vertical, thickness: 2)), + ); + final c = tester.widget(find.byType(Container)); + expect(c.constraints?.maxWidth, 2.0); + }); + }); +} diff --git a/app/test/widgets/src/clide_icon_test.dart b/app/test/widgets/src/clide_icon_test.dart new file mode 100644 index 00000000..59c4f567 --- /dev/null +++ b/app/test/widgets/src/clide_icon_test.dart @@ -0,0 +1,33 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideIcon', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('sizes a SizedBox + CustomPaint to the given size', + (tester) async { + await tester.pumpWidget( + harness(f, const ClideIcon(FolderIcon(), size: 24)), + ); + final sb = tester.widget(find.byType(SizedBox).first); + expect(sb.width, 24); + expect(sb.height, 24); + expect(find.byType(CustomPaint), findsWidgets); + }); + + testWidgets('default color is globalForeground', (tester) async { + await tester.pumpWidget( + harness(f, const ClideIcon(CheckIcon())), + ); + // Color is inaccessible after painting; ensure it renders without crashing. + expect(find.byType(ClideIcon), findsOneWidget); + }); + }); +} diff --git a/app/test/widgets/src/clide_surface_test.dart b/app/test/widgets/src/clide_surface_test.dart new file mode 100644 index 00000000..b25bcfcb --- /dev/null +++ b/app/test/widgets/src/clide_surface_test.dart @@ -0,0 +1,51 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideSurface', () { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('default background comes from panelBackground token', + (tester) async { + await tester.pumpWidget( + harness(f, const ClideSurface(child: Text('x'))), + ); + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration; + expect( + decoration.color, f.services.theme.current.surface.panelBackground); + }); + + testWidgets('explicit color overrides the token default', (tester) async { + const custom = Color(0xFF123456); + await tester.pumpWidget( + harness(f, const ClideSurface(color: custom, child: Text('x'))), + ); + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, custom); + }); + + testWidgets('border when provided renders a BoxBorder', (tester) async { + await tester.pumpWidget( + harness( + f, + ClideSurface( + border: f.services.theme.current.surface.modalSurfaceBorder, + child: const Text('x'), + ), + ), + ); + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration; + expect(decoration.border, isNotNull); + }); + }); +} diff --git a/app/test/widgets/src/clide_tab_bar_test.dart b/app/test/widgets/src/clide_tab_bar_test.dart new file mode 100644 index 00000000..ee2aa2a3 --- /dev/null +++ b/app/test/widgets/src/clide_tab_bar_test.dart @@ -0,0 +1,88 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideTabBar', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders one tab per item', (tester) async { + await tester.pumpWidget( + harness( + f, + ClideTabBar( + items: const [ + ClideTabItem(id: 'a', title: 'A'), + ClideTabItem(id: 'b', title: 'B'), + ], + activeId: 'a', + onSelect: (_) {}, + ), + ), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + }); + + testWidgets('tapping a tab fires onSelect with the id', (tester) async { + String? selected; + await tester.pumpWidget( + harness( + f, + ClideTabBar( + items: const [ + ClideTabItem(id: 'a', title: 'A'), + ClideTabItem(id: 'b', title: 'B'), + ], + activeId: 'a', + onSelect: (id) => selected = id, + ), + ), + ); + await tester.tap(find.text('B')); + expect(selected, 'b'); + }); + + testWidgets('each tab is a selectable Semantics button', (tester) async { + await tester.pumpWidget( + harness( + f, + ClideTabBar( + items: const [ + ClideTabItem(id: 'a', title: 'Files'), + ClideTabItem(id: 'b', title: 'Git'), + ], + activeId: 'a', + onSelect: (_) {}, + ), + ), + ); + final handle = tester.ensureSemantics(); + expect( + tester.getSemantics(find.bySemanticsLabel('Files')), + matchesSemantics( + label: 'Files', + isButton: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Git')), + matchesSemantics( + label: 'Git', + isButton: true, + isSelected: false, + hasSelectedState: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + }); +} diff --git a/app/test/widgets/src/clide_text_test.dart b/app/test/widgets/src/clide_text_test.dart new file mode 100644 index 00000000..56aae5af --- /dev/null +++ b/app/test/widgets/src/clide_text_test.dart @@ -0,0 +1,40 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('ClideText', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders the given string', (tester) async { + await tester.pumpWidget(harness(f, const ClideText('hello world'))); + expect(find.text('hello world'), findsOneWidget); + }); + + testWidgets('default color is globalForeground token', (tester) async { + await tester.pumpWidget(harness(f, const ClideText('x'))); + final text = tester.widget(find.byType(Text)); + final tokens = f.services.theme.current.surface; + expect(text.style!.color, tokens.globalForeground); + }); + + testWidgets('muted mode uses globalTextMuted', (tester) async { + await tester.pumpWidget(harness(f, const ClideText('x', muted: true))); + final text = tester.widget(find.byType(Text)); + expect( + text.style!.color, f.services.theme.current.surface.globalTextMuted); + }); + + testWidgets('explicit color wins over tokens', (tester) async { + await tester.pumpWidget( + harness(f, const ClideText('x', color: Color(0xFFAABBCC)))); + final text = tester.widget(find.byType(Text)); + expect(text.style!.color, const Color(0xFFAABBCC)); + }); + }); +}