add app test suite — unit, widget, golden, a11y (168 tests)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> dependsOn;
|
||||
@override
|
||||
final List<ContributionPoint> contributions;
|
||||
|
||||
final Future<void> Function(ClideExtensionContext ctx)? onActivate;
|
||||
final Future<void> Function()? onDeactivate;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
if (onActivate != null) await onActivate!(ctx);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
final deactivated = <String>[];
|
||||
final s1 = f.services.events
|
||||
.on<ExtensionActivated>()
|
||||
.listen((e) => activated.add(e.id));
|
||||
final s2 = f.services.events
|
||||
.on<ExtensionDeactivated>()
|
||||
.listen((e) => deactivated.add(e.id));
|
||||
f.services.extensions.register(_Ext(id: 'e'));
|
||||
await f.services.extensions.activateAll();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await f.services.extensions.deactivate('e');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(activated, ['e']);
|
||||
expect(deactivated, ['e']);
|
||||
await s1.cancel();
|
||||
await s2.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user