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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user