test(i18n): cover the i18n facade, selector, manifest routing + locale (T-462)
Tests for the epic's new code so the 95% floor holds: ClideSettings.i18n string/interpolated null-safe + localizedCommandTitle; settings-renderer localization (section/field/help/select/file, project-scope tag, didUpdateWidget); extension-activation rollback + settings-contribution unregister; the Dutch gate check; RootShell applying app.locale on boot; and prompt/conversation tool-body + file-ref coverage. Also harden the menu toggle re-tap (capture the button point while stable, then tapAt) so it no longer throws getCenter under the serial coverage load. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,4 +35,20 @@ void main() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The bundled Dutch pack (T-462) must load and cover the same Tier-0 keys, so
|
||||
// a locale switch never falls back to English for a built-in label.
|
||||
group('i18n coverage — Dutch pack (nl-NL)', () {
|
||||
for (final entry in referenced.entries) {
|
||||
final ns = entry.key;
|
||||
test('$ns nl_NL catalog covers every referenced key', () async {
|
||||
final loader = AssetCatalogLoader(bundle: rootBundle);
|
||||
final catalog = await loader.load(ns, const Locale('nl', 'NL'));
|
||||
expect(catalog, isNotEmpty, reason: 'nl_NL catalog for "$ns" failed to load');
|
||||
for (final key in entry.value) {
|
||||
expect(catalog.containsKey(key), isTrue, reason: 'nl_NL "$ns" missing key "$key"');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,4 +453,21 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(f.services.project.isOpen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('RootShell applies the persisted app.locale on boot (T-462)', (tester) async {
|
||||
await tester.runAsync(() async => f.services.settings.set('app.locale', 'nl_NL'));
|
||||
await pumpApp(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
// RootShell.initState parsed app.locale and called i18n.setLocale.
|
||||
expect(f.services.i18n.currentLocale, const Locale('nl', 'NL'));
|
||||
});
|
||||
|
||||
testWidgets('RootShell applies a bare language-only app.locale on boot (T-462)', (tester) async {
|
||||
// A single-part locale ('nl', no region) exercises the Locale(parts[0])
|
||||
// branch in _applyLocale, distinct from the language_REGION form above.
|
||||
await tester.runAsync(() async => f.services.settings.set('app.locale', 'nl'));
|
||||
await pumpApp(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(f.services.i18n.currentLocale, const Locale('nl'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_banner.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
@@ -950,6 +952,90 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// The file-ref open + live-tail handlers read project.current, so these need a
|
||||
// real workspace open. project.open() spawns `git rev-parse` whose exit
|
||||
// ReceivePort is trapped under the fake-async testWidgets zone (T-280) — so the
|
||||
// open is done in setUp (real async), never inside a testWidgets body.
|
||||
group('ConversationView with an open workspace', () {
|
||||
late KernelFixture f;
|
||||
late Directory proj;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
proj = await Directory.systemTemp.createTemp('clide_ws_');
|
||||
await Directory('${proj.path}/.git').create();
|
||||
await File('${proj.path}/lib/app.dart').create(recursive: true);
|
||||
await File('${proj.path}/app.log').writeAsString('starting up\n');
|
||||
await f.services.project.open(proj.path);
|
||||
});
|
||||
tearDown(() async {
|
||||
await f.dispose();
|
||||
if (await proj.exists()) await proj.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) => MediaQuery(
|
||||
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||
child: ConversationView(controller: c, foldLevel: FoldLevel.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final it in items) {
|
||||
stream.add(it);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
return c;
|
||||
}
|
||||
|
||||
testWidgets('clicking a repo file ref opens it in the editor (T-300)', (tester) async {
|
||||
// Capture the editor.open IPC the file-ref tap fires.
|
||||
String? openedPath;
|
||||
int? openedLine;
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
openedPath = args['path'] as String?;
|
||||
openedLine = args['line'] as int?;
|
||||
return IpcResponse.ok(id: '1', data: {'path': args['path']});
|
||||
});
|
||||
|
||||
await pumpWith(tester, [_user('crash at lib/app.dart:42 today')]);
|
||||
await tester.tap(find.text('lib/app.dart:42'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// _resolveRepoFile resolved the ref against project.current, _openFile sent
|
||||
// the absolute path + line to editor.open.
|
||||
expect(openedPath, '${proj.path}/lib/app.dart');
|
||||
expect(openedLine, 42);
|
||||
});
|
||||
|
||||
testWidgets('a tail Bash card follows a real workspace file (T-325)', (tester) async {
|
||||
// The tail command names a single file inside the repo → a followable
|
||||
// source, so _BashLiveTail mounts a terminal instead of the muted note.
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'bt', timestamp: _t, isSidechain: false, toolUseId: 'tbt', name: 'Bash', input: const {'command': 'tail -f app.log'}),
|
||||
]);
|
||||
await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// A resolvable source → the live tail surfaced, NOT the "nothing" note.
|
||||
expect(find.text('live tail'), findsOneWidget);
|
||||
expect(find.text('no independent source to follow'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeBanner', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
|
||||
@@ -611,4 +611,93 @@ void main() {
|
||||
expect(d, isNull, reason: 'typing in the note must not trigger Allow');
|
||||
});
|
||||
});
|
||||
|
||||
group('Enter / number-key on questions (T-240)', () {
|
||||
testWidgets('Enter submits a single answered question (_activatePrimary, q.length<=1)', (tester) async {
|
||||
ToolDecision? d;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, x) => d = x)));
|
||||
await tester.pump(); // autofocus
|
||||
|
||||
// Enter with nothing chosen → gated, no resolve.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(d, isNull, reason: 'Submit is gated until an option is picked');
|
||||
|
||||
// Choose, then Enter → submits the single-question answer.
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect((d as AllowTool).updatedInput['answers']['Do you prefer cats or dogs?'], 'Dogs');
|
||||
});
|
||||
|
||||
testWidgets('Enter advances a mid-step question then submits at review (_activatePrimary multi)', (tester) async {
|
||||
ToolDecision? d;
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, x) => d = x)));
|
||||
await tester.pump(); // autofocus
|
||||
|
||||
// Number key picks an option on the current (first) question — exercises
|
||||
// _currentQuestion returning _step in a multi-question prompt.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.digit2); // Dogs
|
||||
await tester.pump();
|
||||
// Enter on a mid (non-last, non-review) step advances to question 2.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(find.text('How eaten?'), findsOneWidget);
|
||||
|
||||
// Answer #2 via number key, Enter → last step advances to review.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.digit1); // Fresh
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(find.text('Review your answers'), findsOneWidget);
|
||||
|
||||
// Enter on the review step submits both answers.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
final answers = (d as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Which pet?'], 'Dogs');
|
||||
expect(answers['How eaten?'], 'Fresh');
|
||||
});
|
||||
});
|
||||
|
||||
group('Back navigation in the multi-question stepper', () {
|
||||
testWidgets('Back on a mid-step returns to the previous question (_step--)', (tester) async {
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, _) {})));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next ›'));
|
||||
await tester.pump();
|
||||
expect(find.text('How eaten?'), findsOneWidget);
|
||||
|
||||
// On step 2 a Back button is present (only shown when _step > 0).
|
||||
await tester.tap(find.text('‹ Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('Which pet?'), findsOneWidget);
|
||||
expect(find.text('How eaten?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Back on the review step returns to the last question (_step = length-1)', (tester) async {
|
||||
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, _) {})));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next ›'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.textContaining('Fresh'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review ›'));
|
||||
await tester.pump();
|
||||
expect(find.text('Review your answers'), findsOneWidget);
|
||||
|
||||
// The review screen's Back button steps back to the last question.
|
||||
await tester.tap(find.text('‹ Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('How eaten?'), findsOneWidget);
|
||||
expect(find.text('Review your answers'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,12 +97,13 @@ void main() {
|
||||
testWidgets('tapping the same top button toggles the menu closed', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
// Capture the top button's point while the tree is stable (menu closed),
|
||||
// then tapAt it to toggle closed — re-finding 'File' after the overlay opens
|
||||
// can hit a transiently-relaid element and throw in getCenter under load.
|
||||
final fileCenter = tester.getCenter(find.text('File'));
|
||||
await openMenu(tester, 'File');
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
// Let the open overlay finish laying out before re-tapping the top button,
|
||||
// so its hit-test box is stable under load (getCenter would otherwise throw).
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('File'));
|
||||
await tester.tapAt(fileCenter);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 20)); // flush the close under load
|
||||
expect(find.text('Open Folder…'), findsNothing);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
@@ -193,6 +195,37 @@ void main() {
|
||||
expect(f.services.settings.effectiveLayer('app.demo.flag'), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a value stored at project scope shows the This project tag', (tester) async {
|
||||
// project.* keys live only in the project layer, which needs a project dir
|
||||
// open. Point the store at a temp dir and write the key there at project
|
||||
// scope so _appearance(SettingsScope.project) renders (lines 560-562).
|
||||
const projCat = SettingsCategory(
|
||||
id: 'pj',
|
||||
title: 'PJ',
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'S',
|
||||
fields: [SettingsField(key: 'project.pj.flag', kind: SettingsFieldKind.toggle, label: 'ProjFlag', defaultValue: false)],
|
||||
),
|
||||
],
|
||||
);
|
||||
late Directory projDir;
|
||||
await tester.runAsync(() async {
|
||||
projDir = await Directory.systemTemp.createTemp('clide_scope_');
|
||||
await f.services.settings.setProjectDir(projDir);
|
||||
await f.services.settings.setAt(SettingsScope.project, 'project.pj.flag', true);
|
||||
});
|
||||
addTearDown(() async {
|
||||
if (await projDir.exists()) {
|
||||
try {
|
||||
await projDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: projCat))));
|
||||
expect(find.bySemanticsLabel('ProjFlag scope: This project'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('moving an unset value to All clide writes it at app scope', (tester) async {
|
||||
// _other's key (app.other.x) is written by no other test → pristine here.
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _other))));
|
||||
@@ -269,6 +302,28 @@ void main() {
|
||||
expect(f.services.settings.get<Object>('app.nr.size'), isNot('abc'));
|
||||
});
|
||||
|
||||
testWidgets('a text field reflects an external value change when not focused (didUpdateWidget)', (tester) async {
|
||||
// Dedicated key — the store is shared across tests in this file.
|
||||
const extCat = SettingsCategory(
|
||||
id: 'tx',
|
||||
title: 'TX',
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'S',
|
||||
fields: [SettingsField(key: 'app.tx.name', kind: SettingsFieldKind.text, label: 'Name', defaultValue: 'initial')],
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: extCat))));
|
||||
expect(find.text('initial'), findsOneWidget);
|
||||
// External write (e.g. a reset or another surface) with the field unfocused
|
||||
// → the ListenableBuilder rebuilds _EditControl with a new value, and
|
||||
// didUpdateWidget pushes it into the controller (lines 433-438).
|
||||
await tester.runAsync(() => f.services.settings.set('app.tx.name', 'updated'));
|
||||
await tester.pump();
|
||||
expect(find.text('updated'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a text field commits the trimmed value', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: textCat))));
|
||||
await tester.enterText(find.byType(EditableText), ' hello ');
|
||||
@@ -372,4 +427,65 @@ void main() {
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('i18n localization (T-462)', () {
|
||||
testWidgets('section/field/help labels resolve through the category namespace', (tester) async {
|
||||
// KernelFixture.create does real temp-dir I/O; run it on the real event
|
||||
// loop (not the fake-async testWidgets body) or it traps under load (T-122).
|
||||
late KernelFixture lf;
|
||||
await tester.runAsync(
|
||||
() async => lf = await KernelFixture.create(
|
||||
i18nCatalogs: {
|
||||
'test.loc': {
|
||||
const Locale('en', 'US'): const {
|
||||
'sec.label': {'translation': 'LOCSEC'},
|
||||
'fld.label': {'translation': 'LocField'},
|
||||
'fld.help': {'translation': 'LocHelp'},
|
||||
'sel.label': {'translation': 'LocSelect'},
|
||||
'opt.a': {'translation': 'LocOptA'},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
addTearDown(lf.dispose);
|
||||
const cat = SettingsCategory(
|
||||
id: 'loc',
|
||||
title: 'EngCat',
|
||||
i18nNamespace: 'test.loc',
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'EngSec',
|
||||
labelKey: 'sec.label',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: 'app.loc.flag',
|
||||
kind: SettingsFieldKind.toggle,
|
||||
label: 'EngField',
|
||||
labelKey: 'fld.label',
|
||||
help: 'EngHelp',
|
||||
helpKey: 'fld.help',
|
||||
defaultValue: false,
|
||||
),
|
||||
SettingsField(
|
||||
key: 'app.loc.sel',
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'EngSelect',
|
||||
labelKey: 'sel.label',
|
||||
defaultValue: 'a',
|
||||
options: [SettingsOption(value: 'a', label: 'EngOptA', labelKey: 'opt.a')],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(harness(lf, _bounded(const SettingsCategoryView(category: cat))));
|
||||
expect(find.text('LOCSEC'), findsOneWidget); // section header from the catalog
|
||||
expect(find.text('LocField'), findsOneWidget); // field label localized
|
||||
expect(find.text('LocHelp'), findsOneWidget); // help localized
|
||||
expect(find.text('LocSelect'), findsOneWidget); // select field label localized
|
||||
expect(find.text('LocOptA'), findsOneWidget); // select shows the localized current option
|
||||
expect(find.text('EngField'), findsNothing); // English placeholder replaced
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ void main() {
|
||||
'panel.empty': {'translation': 'No settings categories are registered yet.'},
|
||||
},
|
||||
},
|
||||
// Namespace for a category whose rail title resolves via titleKey.
|
||||
'test.railcat': {
|
||||
const Locale('en', 'US'): const {
|
||||
'cat.title': {'translation': 'Localized Rail'},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -64,5 +70,27 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(dismissed, 1);
|
||||
});
|
||||
|
||||
testWidgets('a rail row resolves its title via titleKey + namespace (T-462)', (tester) async {
|
||||
// A category with titleKey + i18nNamespace → the rail localizes the row
|
||||
// title through the catalog instead of the English fallback (line 251).
|
||||
f.services.settingsRegistry.register(
|
||||
const SettingsCategory(
|
||||
id: 'railcat',
|
||||
title: 'EngRail',
|
||||
titleKey: 'cat.title',
|
||||
i18nNamespace: 'test.railcat',
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'S',
|
||||
fields: [SettingsField(key: 'app.railcat.flag', kind: SettingsFieldKind.toggle, label: 'F', defaultValue: false)],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {})));
|
||||
expect(find.text('Localized Rail'), findsOneWidget);
|
||||
expect(find.text('EngRail'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,6 +295,31 @@ void main() {
|
||||
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+alt+j')), isNull);
|
||||
});
|
||||
|
||||
test('Settings category + control contributions register on activate and unregister on deactivate', () async {
|
||||
f.services.extensions.register(
|
||||
_Ext(
|
||||
id: 'settings-ext',
|
||||
contributions: const [
|
||||
SettingsCategoryContribution(
|
||||
id: 'settings-ext.cat',
|
||||
category: SettingsCategory(id: 'sx', title: 'SX', sections: []),
|
||||
),
|
||||
SettingsControlContribution(id: 'settings-ext.ctl', customId: 'sx.control', builder: _buildNothing),
|
||||
],
|
||||
),
|
||||
);
|
||||
await f.services.extensions.activateAll();
|
||||
// Both contributions are applied (category via SettingsRegistry.register,
|
||||
// control via SettingsControlRegistry.register — lines 268-272).
|
||||
expect(f.services.settingsRegistry.byId('sx')?.title, 'SX');
|
||||
expect(f.services.settingsControlRegistry.builderFor('sx.control'), isNotNull);
|
||||
|
||||
await f.services.extensions.deactivate('settings-ext');
|
||||
// Deactivation unwinds both via _removeContribution (lines 293-296).
|
||||
expect(f.services.settingsRegistry.byId('sx'), isNull);
|
||||
expect(f.services.settingsControlRegistry.builderFor('sx.control'), isNull);
|
||||
});
|
||||
|
||||
test('all getter yields every registered extension', () async {
|
||||
f.services.extensions.register(_Ext(id: 'a-iter'));
|
||||
f.services.extensions.register(_Ext(id: 'b-iter'));
|
||||
@@ -405,6 +430,76 @@ void main() {
|
||||
expect(f.services.extensions.isActivated('base'), isFalse, reason: 'allowed once the dependent is gone');
|
||||
});
|
||||
|
||||
test('a throw AFTER activate() succeeds unwinds mounted contributions AND runs deactivate()', () async {
|
||||
// activate() succeeds (so extActivated is set), the first command
|
||||
// contribution mounts, then a mid-list duplicate command id throws in
|
||||
// _applyContribution — exercising the rollback path: the already-applied
|
||||
// contribution is unwound and the extension's deactivate() is called
|
||||
// because its own activate() had succeeded (lines 184-200).
|
||||
var deactivated = false;
|
||||
f.services.extensions.register(
|
||||
_Ext(
|
||||
id: 'two-cmds-second-dupes',
|
||||
contributions: [
|
||||
CommandContribution(
|
||||
id: 'first-ok',
|
||||
command: 'rollback.first',
|
||||
run: (_) async => IpcResponse.ok(id: ''),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'second-dupe',
|
||||
command: 'rollback.first',
|
||||
run: (_) async => IpcResponse.ok(id: ''),
|
||||
),
|
||||
],
|
||||
onActivate: (_) async {}, // succeeds → extActivated = true
|
||||
onDeactivate: () async => deactivated = true,
|
||||
),
|
||||
);
|
||||
await f.services.extensions.activateAll();
|
||||
|
||||
// Rolled back: not activated, marked failed, and the already-mounted
|
||||
// first command is gone (the unwind ran _removeContribution on it).
|
||||
expect(f.services.extensions.isActivated('two-cmds-second-dupes'), isFalse);
|
||||
expect(f.services.extensions.didFail('two-cmds-second-dupes'), isTrue);
|
||||
expect(f.services.commands.get('rollback.first'), isNull, reason: 'the first command must be unwound');
|
||||
// activate() had succeeded → deactivate() ran during rollback.
|
||||
expect(deactivated, isTrue);
|
||||
// failedExtensions getter exposes the recorded error for the UI badge.
|
||||
expect(f.services.extensions.failedExtensions.containsKey('two-cmds-second-dupes'), isTrue);
|
||||
});
|
||||
|
||||
test('a deactivate() that throws DURING rollback is caught and logged', () async {
|
||||
// activate() succeeds, the first command mounts, the duplicate throws,
|
||||
// and the rollback-triggered deactivate() ALSO throws — the inner catch
|
||||
// swallows + logs it rather than masking the original failure (line 197).
|
||||
f.services.extensions.register(
|
||||
_Ext(
|
||||
id: 'rollback-deactivate-throws',
|
||||
contributions: [
|
||||
CommandContribution(
|
||||
id: 'rdt.first',
|
||||
command: 'rdt.cmd',
|
||||
run: (_) async => IpcResponse.ok(id: ''),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'rdt.second',
|
||||
command: 'rdt.cmd',
|
||||
run: (_) async => IpcResponse.ok(id: ''),
|
||||
),
|
||||
],
|
||||
onActivate: (_) async {}, // succeeds → extActivated = true
|
||||
onDeactivate: () async => throw StateError('teardown also fails'),
|
||||
),
|
||||
);
|
||||
await f.services.extensions.activateAll();
|
||||
|
||||
// Original failure still wins: not activated, marked failed, unwound.
|
||||
expect(f.services.extensions.isActivated('rollback-deactivate-throws'), isFalse);
|
||||
expect(f.services.extensions.didFail('rollback-deactivate-throws'), isTrue);
|
||||
expect(f.services.commands.get('rdt.cmd'), isNull, reason: 'the first command must still be unwound');
|
||||
});
|
||||
|
||||
test('a duplicate contribution id fails the second activation', () async {
|
||||
f.services.extensions
|
||||
..register(
|
||||
@@ -431,3 +526,5 @@ void main() {
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
|
||||
Widget _buildNothing(BuildContext _) => const SizedBox.shrink();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/extension/extension.dart' show CommandContribution;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -40,4 +42,66 @@ void main() {
|
||||
expect(mono, 'FiraMono');
|
||||
expect(ui, 'Inter');
|
||||
});
|
||||
|
||||
testWidgets('i18n.string returns the placeholder without a kernel (T-462)', (tester) async {
|
||||
late String s;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
s = ClideSettings.i18n.string(context, 'k', namespace: 'x', placeholder: 'Fallback');
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(s, 'Fallback');
|
||||
});
|
||||
|
||||
testWidgets('i18n.interpolated applies replacers to the placeholder without a kernel', (tester) async {
|
||||
late String s;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
s = ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'k',
|
||||
namespace: 'x',
|
||||
placeholder: 'Hi {name}',
|
||||
replacers: [I18nReplacer(from: '{name}', replace: 'Jeroen')],
|
||||
);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(s, 'Hi Jeroen');
|
||||
});
|
||||
|
||||
testWidgets('localizedCommandTitle resolves titleKey, else falls back to the title (T-462)', (tester) async {
|
||||
final withKey = CommandContribution(
|
||||
id: 'a',
|
||||
command: 'a',
|
||||
title: 'Eng A',
|
||||
titleKey: 'cmd.a',
|
||||
i18nNamespace: 'x',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
);
|
||||
final noKey = CommandContribution(
|
||||
id: 'b',
|
||||
command: 'b',
|
||||
title: 'Eng B',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
);
|
||||
late String a;
|
||||
late String b;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
a = localizedCommandTitle(context, withKey); // no kernel → placeholder (the title)
|
||||
b = localizedCommandTitle(context, noKey);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(a, 'Eng A');
|
||||
expect(b, 'Eng B');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,6 +162,38 @@ void main() {
|
||||
expect(find.text('Hoverable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hovering a NON-selected palette row paints the hover background', (tester) async {
|
||||
// The first row is selected (selectedIndex 0), so its color comes from the
|
||||
// `selected` branch. Hovering the SECOND (unselected) row exercises the
|
||||
// `_hover ? listItemHoverBackground` arm that the selected row skips.
|
||||
f.services.commands.register(
|
||||
CommandContribution(
|
||||
id: 'h1',
|
||||
command: 'first.cmd',
|
||||
title: 'FirstRow',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
),
|
||||
);
|
||||
f.services.commands.register(
|
||||
CommandContribution(
|
||||
id: 'h2',
|
||||
command: 'second.cmd',
|
||||
title: 'SecondRow',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
),
|
||||
);
|
||||
f.services.palette.open();
|
||||
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
await gesture.moveTo(tester.getCenter(find.text('SecondRow')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('SecondRow'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('arrow keys move the highlighted command (T-100)', (tester) async {
|
||||
for (final id in ['a', 'b', 'c']) {
|
||||
f.services.commands.register(
|
||||
|
||||
Reference in New Issue
Block a user