feat(tokens): add design tokens, type scale, push scaffold, and root nav state

This commit is contained in:
2026-07-28 20:01:23 +02:00
parent 6dba89432e
commit 172b2a42ac
17 changed files with 856 additions and 30 deletions
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_navegacion.dart';
/// Design ADR-8: EstadoNavegacionRaiz is the single source of truth for
/// which of the 5 root tabs is active. Switching roots never pushes a
/// route — this is a plain ChangeNotifier update.
void main() {
group('RaizPluriWave', () {
test('declaration order is the tab order (0-indexed)', () {
expect(RaizPluriWave.escuchar.index, 0);
expect(RaizPluriWave.buscar.index, 1);
expect(RaizPluriWave.favoritos.index, 2);
expect(RaizPluriWave.alarmas.index, 3);
expect(RaizPluriWave.ajustes.index, 4);
expect(RaizPluriWave.values.length, 5);
});
});
group('EstadoNavegacionRaiz', () {
test('starts on escuchar', () {
final estado = EstadoNavegacionRaiz();
expect(estado.actual, RaizPluriWave.escuchar);
expect(estado.indice, 0);
});
test('irA transitions to the requested root', () {
final estado = EstadoNavegacionRaiz();
estado.irA(RaizPluriWave.favoritos);
expect(estado.actual, RaizPluriWave.favoritos);
});
test('indice mirrors the enum declaration order after a transition', () {
final estado = EstadoNavegacionRaiz();
estado.irA(RaizPluriWave.alarmas);
expect(estado.indice, RaizPluriWave.alarmas.index);
expect(estado.indice, 3);
});
test('irA notifies listeners on a real transition', () {
final estado = EstadoNavegacionRaiz();
var notifyCount = 0;
estado.addListener(() => notifyCount++);
estado.irA(RaizPluriWave.buscar);
expect(notifyCount, 1);
});
test('irA no-ops and does not notify when already on that root', () {
final estado = EstadoNavegacionRaiz();
var notifyCount = 0;
estado.addListener(() => notifyCount++);
estado.irA(RaizPluriWave.escuchar); // already the starting root
expect(notifyCount, 0);
expect(estado.actual, RaizPluriWave.escuchar);
});
test('a repeated irA to the same non-initial root only notifies once', () {
final estado = EstadoNavegacionRaiz();
estado.irA(RaizPluriWave.ajustes);
var notifyCount = 0;
estado.addListener(() => notifyCount++);
estado.irA(RaizPluriWave.ajustes);
expect(notifyCount, 0);
});
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
/// Design ADR-1: 3 new colour fields join the existing PluriWaveTokens
/// extension (colours belong here because they must participate in lerp).
/// Dedicated file under test/tema/ so the WU1 verify command
/// (`flutter test test/tema/ ...`) discovers it; the single pre-existing
/// PluriWaveTokens assertion inside pluriwave_foundations_test.dart is left
/// untouched.
void main() {
group('PluriWaveTokens — redesign colours', () {
test('dark keeps the pre-existing base palette', () {
expect(PluriWaveTokens.dark.deepViolet, const Color(0xFF07121A));
expect(PluriWaveTokens.dark.radiusMd, 22);
expect(PluriWaveTokens.dark.spacingMd, 16);
});
test(
'dark exposes listSurface and liveGreen de-literalised from theme.dart',
() {
// Design ADR-1: these match the theme's previous raw literals
// (surfaceContainerLow / secondary) exactly, so promoting them to
// named tokens keeps rendered output byte-identical.
expect(PluriWaveTokens.dark.listSurface, const Color(0xFF102532));
expect(PluriWaveTokens.dark.liveGreen, const Color(0xFF7EE4C2));
},
);
test('dark exposes offlineAccent as a genuinely new colour', () {
expect(PluriWaveTokens.dark.offlineAccent, const Color(0xFF94A3B8));
});
test('copyWith overrides only the new colour fields', () {
final overridden = PluriWaveTokens.dark.copyWith(
listSurface: Colors.black,
liveGreen: Colors.black,
offlineAccent: Colors.black,
);
expect(overridden.listSurface, Colors.black);
expect(overridden.liveGreen, Colors.black);
expect(overridden.offlineAccent, Colors.black);
// Untouched fields keep their original value.
expect(overridden.deepViolet, PluriWaveTokens.dark.deepViolet);
});
test('lerp interpolates listSurface, liveGreen and offlineAccent', () {
const a = PluriWaveTokens.dark;
final b = a.copyWith(
listSurface: Colors.white,
liveGreen: Colors.white,
offlineAccent: Colors.white,
);
final mid = a.lerp(b, 0.5);
expect(mid.listSurface, Color.lerp(a.listSurface, Colors.white, 0.5));
expect(mid.liveGreen, Color.lerp(a.liveGreen, Colors.white, 0.5));
expect(mid.offlineAccent, Color.lerp(a.offlineAccent, Colors.white, 0.5));
});
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_typography.dart';
/// Design ADR-1: the named type scale is a second ThemeExtension, closed at
/// six styles. Pins the documented size/weight/letter-spacing per style and
/// confirms PluriWaveTheme.dark() registers it, mirroring how
/// PluriWaveTokens/PluriWaveMotion are already tested.
///
/// PluriWaveTheme.dark() is only ever resolved from inside a testWidgets
/// callback (never from setUpAll or a plain test()). GoogleFonts.
/// plusJakartaSansTextTheme fires a background network fetch per text role;
/// TestWidgetsFlutterBinding only installs its HTTP-blocking override
/// inside an active test-widgets zone, so calling PluriWaveTheme.dark()
/// outside one lets a real (and here unreachable) network request escape
/// and fail asynchronously. The numeric values asserted below (fontSize/
/// weight/letterSpacing/height) are set synchronously by
/// PluriWaveTypography.from regardless of that background fetch's outcome.
void main() {
late PluriWaveTypography type;
group('PluriWaveTypography', () {
testWidgets('PluriWaveTheme.dark() registers the extension', (
tester,
) async {
final theme = PluriWaveTheme.dark();
final extension = theme.extension<PluriWaveTypography>();
expect(extension, isNotNull);
type = extension!;
});
test('heroTime is 88 / w800 / height 1.0 / letter-spacing -2.0', () {
expect(type.heroTime.fontSize, 88);
expect(type.heroTime.fontWeight, FontWeight.w800);
expect(type.heroTime.height, 1.0);
expect(type.heroTime.letterSpacing, -2.0);
});
test('sectionTitle is 23 / w800 / letter-spacing -0.6', () {
expect(type.sectionTitle.fontSize, 23);
expect(type.sectionTitle.fontWeight, FontWeight.w800);
expect(type.sectionTitle.letterSpacing, -0.6);
});
test('screenTitle is 19 / w800 / letter-spacing -0.4', () {
expect(type.screenTitle.fontSize, 19);
expect(type.screenTitle.fontWeight, FontWeight.w800);
expect(type.screenTitle.letterSpacing, -0.4);
});
test('cardTitle is 14.5 / w700', () {
expect(type.cardTitle.fontSize, 14.5);
expect(type.cardTitle.fontWeight, FontWeight.w700);
});
test('bodyStrong is 13 / w600', () {
expect(type.bodyStrong.fontSize, 13);
expect(type.bodyStrong.fontWeight, FontWeight.w600);
});
test('eyebrowLabel is 11 / w800 / letter-spacing 0.8', () {
expect(type.eyebrowLabel.fontSize, 11);
expect(type.eyebrowLabel.fontWeight, FontWeight.w800);
expect(type.eyebrowLabel.letterSpacing, 0.8);
});
testWidgets('context.pluriType exposes the registered extension', (
tester,
) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
theme: PluriWaveTheme.dark(),
home: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
},
),
),
);
expect(capturedContext.pluriType.cardTitle.fontSize, 14.5);
});
});
}
+55
View File
@@ -3,7 +3,12 @@
// existe en este proyecto — corregido para usar PluriWaveApp.
// Los tests de integración completos (audio, streaming) requieren un dispositivo.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/widgets/pluri_bottom_navigation.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
void main() {
testWidgets('Placeholder — tests de integración requieren dispositivo', (
@@ -14,4 +19,54 @@ void main() {
// por el test de smoke incorrecto del boilerplate original.
expect(true, isTrue);
});
// Spec app-navigation-shell — "Escuchar Tab Rename Preserves the ARB Key"
// and "Escuchar uses the home glyph": the former "Inicio" tab is
// relabeled by changing navHome's VALUE only; PluriIconGlyph.home stays
// its icon. app.dart's real _navItems()/_paginas wiring is private to
// that library, so this pins the same two contracts at the pieces it
// composes: the ARB value, the PluriNavItem the tab is built from, and
// that PluriIconGlyph.home still resolves to a renderable icon.
group('Escuchar tab rename (WU1)', () {
test('navHome value renamed in en/es without touching the key', () async {
final l10nEs = await AppLocalizations.delegate.load(const Locale('es'));
final l10nEn = await AppLocalizations.delegate.load(const Locale('en'));
expect(l10nEs.navHome, 'Escuchar');
expect(l10nEn.navHome, 'Listen');
});
test(
'the home tab item still pairs navHome with PluriIconGlyph.home',
() async {
final l10n = await AppLocalizations.delegate.load(const Locale('es'));
final item = PluriNavItem(
glyph: PluriIconGlyph.home,
label: l10n.navHome,
);
expect(item.label, 'Escuchar');
expect(item.glyph, PluriIconGlyph.home);
},
);
testWidgets('PluriIconGlyph.home still renders as the home icon', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: PluriWaveTheme.dark(),
home: const Scaffold(
body: PluriIcon(
glyph: PluriIconGlyph.home,
semanticLabel: 'Escuchar',
),
),
),
);
expect(find.bySemanticsLabel('Escuchar'), findsOneWidget);
});
});
}
+280
View File
@@ -0,0 +1,280 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_busqueda.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Design ADR-2 enforcement test: chrome is decided by route topology, not a
/// parameter. Mounting each of the 5 root screens bare (no ancestor
/// PluriWaveScaffold/Scaffold) must render zero Scaffolds — "no tab bar on
/// second-level screens" becomes a structural fact, not a convention. A
/// PluriPushScaffold, by contrast, is the ONE place a second-level screen
/// gets a Scaffold, and it must render exactly one 56px AppBar with a back
/// affordance.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Widget testApp(Widget home) {
return MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// No Scaffold ancestor here on purpose — a bare Material is enough
// for InkWell/Card without secretly providing the very Scaffold this
// test asserts is absent.
home: Material(child: home),
);
}
Future<File> archivoCustomVacio() async => File(
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
);
// Existing project convention (see pantalla_inicio_test.dart et al.): the
// default 800x600 test surface clips these content-heavy root screens.
void setLargeSurface(WidgetTester tester) {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
// Pre-existing project constraint (see pantalla_ajustes_test.dart):
// PluriGlassSurface paints a background over ListTile's ink layer, which
// Flutter flags as a warning-level assertion, not a correctness bug.
void suppressListTileInkAssertion() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains(
'ListTile background color or ink splashes may be invisible',
)) {
return;
}
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
group('The 5 root screens build zero Scaffold when mounted bare', () {
testWidgets('PantallaInicio', (tester) async {
setLargeSurface(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
],
child: testApp(const PantallaInicio()),
),
);
await tester.pump();
expect(find.byType(Scaffold), findsNothing);
});
testWidgets('PantallaBuscar', (tester) async {
setLargeSurface(tester);
final busqueda = EstadoBusqueda(radio: FakeServicioRadio());
addTearDown(busqueda.dispose);
await tester.pumpWidget(
ListenableProvider<EstadoBusqueda>.value(
value: busqueda,
child: testApp(const PantallaBuscar()),
),
);
await tester.pump();
expect(find.byType(Scaffold), findsNothing);
});
testWidgets('PantallaFavoritos', (tester) async {
setLargeSurface(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: testApp(const PantallaFavoritos()),
),
);
await tester.pump();
expect(find.byType(Scaffold), findsNothing);
});
testWidgets('PantallaAlarmas', (tester) async {
setLargeSurface(tester);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: DateTime.now),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoAlarmas>.value(
value: estadoAlarmas,
child: testApp(const PantallaAlarmas()),
),
);
await tester.pump();
expect(find.byType(Scaffold), findsNothing);
});
testWidgets('PantallaAjustes', (tester) async {
setLargeSurface(tester);
suppressListTileInkAssertion();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
final estadoIdioma = EstadoIdioma();
addTearDown(estadoIdioma.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoIdioma>.value(value: estadoIdioma),
],
child: testApp(const PantallaAjustes()),
),
);
await tester.pump();
expect(find.byType(Scaffold), findsNothing);
});
});
group('PluriPushScaffold', () {
testWidgets('renders exactly one 56px AppBar with a back affordance', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Navigator(
onGenerateRoute:
(settings) => MaterialPageRoute<void>(
builder:
(_) => const PluriPushScaffold(
title: 'Detalle',
body: SizedBox.shrink(),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(Scaffold), findsOneWidget);
expect(find.byType(AppBar), findsOneWidget);
final appBar = tester.widget<AppBar>(find.byType(AppBar));
expect(appBar.toolbarHeight, PluriPushScaffold.headerHeight);
expect(PluriPushScaffold.headerHeight, 56);
expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget);
expect(find.text('Detalle'), findsOneWidget);
});
// Design ADR-2's load-bearing contract: PluriPushScaffold has NO
// bottomNavigationBar parameter. This is a type-system guarantee, not a
// runtime one — passing `bottomNavigationBar: ...` below would simply
// fail to compile. There is nothing to assert dynamically; the absence
// of that parameter IS the enforcement.
testWidgets('push navigates via Navigator.push (route depth increases)', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Material(
child: Center(
child: ElevatedButton(
onPressed:
() => PluriPushScaffold.push(
context,
(_) => const PluriPushScaffold(
title: 'Empujada',
body: SizedBox.shrink(),
),
),
child: const Text('abrir'),
),
),
),
),
),
);
expect(find.byType(PluriPushScaffold), findsNothing);
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('Empujada'), findsOneWidget);
});
});
}