chain-studio/test/workspace_switcher_test.dart
flemming-it 64c2a77dc9 feat(workspace): one global switcher anchor in the shell sidebar
The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.

Also in this rebuild:

* Stopped sealed areas ask before starting ("Start area X?") —
  a context switch must never boot a hub daemon as a click
  side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
  it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
  reveal client identities) and links to the Settings toggle
  (Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
  use; sealed names honour the privacy setting — while hidden,
  the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
  cause ("No runs in project X" + show-all action) instead of
  claiming the feature is off.

Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-08-28 00:00:06 +02:00

303 lines
9.6 KiB
Dart

// Workspace anchor — contract: the sidebar's ONE switcher control
// renders the active selection, lists "All projects" plus every
// registry project (colour dot, shield for protected, checkmark on
// the active entry), keeps sealed-area names aggregated until
// deliberately revealed, asks before starting a stopped area, and
// switching updates the shared Workspace notifier.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/data/sealed_areas.dart';
import 'package:chain_studio/data/workspace.dart';
import 'package:chain_studio/data/workspace_prefs.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
import 'support/fake_hub.dart';
Widget _host() {
return const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 220,
height: 44,
child: ChainWorkspaceAnchor(
t: 1,
labelsInteractive: true,
iconColumnWidth: 72,
),
),
),
),
);
}
const _general = ProjectRef(slug: 'general', name: 'General');
const _clientA = ProjectRef(
slug: 'client-a',
name: 'Client A',
color: '#8b7cf6',
isolation: 'protected',
);
void main() {
late FakeHubService fakeHub;
// Seed the workspace singleton AND script both service fakes to
// match: opening the menu triggers Workspace.refresh, which must
// answer from the fakes — never from a live hub or the real
// ~/.chain (hermeticity, shared/TESTING.md).
void seed({
List<ProjectRef> projects = const [_general, _clientA],
String active = '',
List<SealedArea> sealed = const [],
SealedArea? activeSealed,
}) {
fakeHub.projects = projects;
SealedAreaService.debugSetInstance(_FakeSealedAreas(sealed));
Workspace.instance.debugSeed(
projects: projects,
active: active,
sealed: sealed,
activeSealed: activeSealed,
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
fakeHub = installFakeHub();
addTearDown(() => SealedAreaService.debugSetInstance(null));
seed();
});
tearDown(() {
WorkspacePrefs.sealedNamesVisible.value = false;
WorkspacePrefs.recentContexts.value = const [];
});
testWidgets('sealed areas stay aggregated until deliberately revealed', (
tester,
) async {
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// No names on a casual glance — only the aggregate row.
expect(find.text('grid'), findsNothing);
expect(find.text('lab'), findsNothing);
expect(find.text('2 sealed areas'), findsOneWidget);
await tester.tap(find.text('Show names'));
await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget);
});
testWidgets('the aggregate row explains itself and links to Settings', (
tester,
) async {
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// The why-line: aggregation is a confidentiality decision and
// must say so in place (in-app docs rule), plus the jump to the
// Settings toggle.
expect(
find.textContaining('reveal client identities'),
findsOneWidget,
);
expect(find.textContaining('Settings'), findsWidgets);
});
testWidgets('the Settings toggle restores the direct listing', (
tester,
) async {
WorkspacePrefs.sealedNamesVisible.value = true;
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget);
expect(find.text('2 sealed areas'), findsNothing);
});
testWidgets('shows "All projects" when no project is active', (
tester,
) async {
await tester.pumpWidget(_host());
expect(find.text('All projects'), findsOneWidget);
});
testWidgets('menu lists all-projects entry plus every registry project', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
expect(find.text('All projects'), findsWidgets);
expect(find.text('General'), findsOneWidget);
expect(find.text('Client A'), findsOneWidget);
// Protected projects carry the shield marker.
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
});
testWidgets('the active entry carries the checkmark', (tester) async {
seed(active: 'client-a');
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// Exactly one checkmark, and it sits in the active project's row.
expect(find.byIcon(Icons.check), findsOneWidget);
expect(
find.descendant(
of: find.ancestor(
of: find.text('Client A'),
matching: find.byType(PopupMenuItem<String>),
),
matching: find.byIcon(Icons.check),
),
findsOneWidget,
);
});
testWidgets('selecting a project updates the workspace and the label', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
await tester.tap(find.text('Client A').last);
await tester.pumpAndSettle();
expect(Workspace.instance.activeSlug, 'client-a');
expect(Workspace.instance.active?.isProtected, isTrue);
// The closed control now shows the active project.
expect(find.text('Client A'), findsOneWidget);
});
test('active falls back to null when the slug left the registry', () {
Workspace.instance.debugSeed(projects: [_general], active: 'gone');
expect(Workspace.instance.activeSlug, 'gone');
expect(Workspace.instance.active, isNull);
expect(Workspace.instance.isAll, isFalse);
});
testWidgets('lists sealed areas with lock + running/stopped status', (
tester,
) async {
seed(
projects: [_general],
sealed: const [
SealedArea(
slug: 'grid',
name: 'Grid',
color: '#e0a458',
port: 51100,
running: true,
),
SealedArea(
slug: 'bank',
name: 'Bank',
color: '#c25e5e',
port: 51101,
running: false,
),
],
);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
// Sealed areas appear under the sealed header, aggregated by
// default (confidentiality); after the reveal every area shows
// its lock icon and running/stopped status word.
expect(find.text('SEALED AREAS'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
await tester.tap(find.text('Show names'));
await tester.pumpAndSettle();
expect(find.text('Grid'), findsOneWidget);
expect(find.text('Bank'), findsOneWidget);
expect(find.text('running'), findsOneWidget);
expect(find.text('stopped'), findsOneWidget);
});
testWidgets('selecting a STOPPED area asks before starting its hub', (
tester,
) async {
WorkspacePrefs.sealedNamesVisible.value = true;
seed(projects: [_general], sealed: [_sealedGrid]); // grid: stopped
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceAnchor));
await tester.pumpAndSettle();
await tester.tap(find.text('grid'));
await tester.pumpAndSettle();
// The confirmation dialog — an area switch must never boot a
// hub daemon as a click side-effect.
expect(find.text('Start area “grid”?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(Workspace.instance.inSealedArea, isFalse);
});
testWidgets('the anchor shows the active sealed area with a lock', (
tester,
) async {
seed(
projects: [_general],
activeSealed: const SealedArea(
slug: 'grid',
name: 'Grid',
color: '#e0a458',
port: 51100,
running: true,
),
);
await tester.pumpWidget(_host());
expect(Workspace.instance.inSealedArea, isTrue);
// The closed anchor names the sealed area and carries a lock.
expect(find.text('Grid'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
});
}
class _FakeSealedAreas extends SealedAreaService {
_FakeSealedAreas(this.areas) : super.forTest();
final List<SealedArea> areas;
@override
Future<List<SealedArea>> list() async => areas;
@override
Future<String?> boundEndpoint(String slug) async => null;
}
// Sealed-area confidentiality (usertest security finding): the
// switcher must not disclose sealed-area names — often client
// identity — on a casual glance. Aggregated row by default,
// deliberate reveal, Settings toggle for the direct listing.
const _sealedGrid = SealedArea(
slug: 'grid',
name: 'grid',
color: '#e0a458',
port: 51100,
running: false,
);
const _sealedLab = SealedArea(
slug: 'lab',
name: 'lab',
color: '#c25e5e',
port: 51101,
running: true,
);