// 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 projects = const [_general, _clientA], String active = '', List 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), ), 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 areas; @override Future> list() async => areas; @override Future 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, );