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>
303 lines
9.6 KiB
Dart
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,
|
|
);
|