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 00000000..dd315388 Binary files /dev/null and b/app/test/goldens/goldens/ci/clide_button.png differ 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 00000000..ed6cf4d2 Binary files /dev/null and b/app/test/goldens/goldens/ci/clide_icon.png differ 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 00000000..36457abf Binary files /dev/null and b/app/test/goldens/goldens/ci/clide_tab_bar.png differ 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 00000000..a489d817 Binary files /dev/null and b/app/test/goldens/goldens/linux/clide_button.png differ 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 00000000..6f39bc98 Binary files /dev/null and b/app/test/goldens/goldens/linux/clide_icon.png differ 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 00000000..a2613874 Binary files /dev/null and b/app/test/goldens/goldens/linux/clide_tab_bar.png differ 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)); + }); + }); +}