feat(quality): harden lint rules and add quality-gate tests
This commit is contained in:
@@ -23,6 +23,11 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
unawaited_futures: true
|
||||||
|
prefer_final_locals: true
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ class EstadoIdioma extends ChangeNotifier {
|
|||||||
final partes = value.split('_');
|
final partes = value.split('_');
|
||||||
final languageCode = partes.first;
|
final languageCode = partes.first;
|
||||||
if (languageCode.isEmpty) return null;
|
if (languageCode.isEmpty) return null;
|
||||||
final countryCode = partes.length > 1 && partes[1].isNotEmpty
|
final countryCode =
|
||||||
? partes[1]
|
partes.length > 1 && partes[1].isNotEmpty ? partes[1] : null;
|
||||||
: null;
|
|
||||||
return Locale.fromSubtags(
|
return Locale.fromSubtags(
|
||||||
languageCode: languageCode,
|
languageCode: languageCode,
|
||||||
countryCode: countryCode,
|
countryCode: countryCode,
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ class Emisora {
|
|||||||
/// Lista de géneros/tags como lista limpia.
|
/// Lista de géneros/tags como lista limpia.
|
||||||
List<String> get generos {
|
List<String> get generos {
|
||||||
if (tags == null || tags!.isEmpty) return [];
|
if (tags == null || tags!.isEmpty) return [];
|
||||||
return tags!.split(',').map((t) => t.trim()).where((t) => t.isNotEmpty).toList();
|
return tags!
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.where((t) => t.isNotEmpty)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _nonEmpty(String? s) =>
|
static String? _nonEmpty(String? s) =>
|
||||||
@@ -148,7 +152,9 @@ class Emisora {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
other is Emisora && runtimeType == other.runtimeType && uuid == other.uuid;
|
other is Emisora &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
uuid == other.uuid;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => uuid.hashCode;
|
int get hashCode => uuid.hashCode;
|
||||||
|
|||||||
@@ -7,19 +7,45 @@ class PresetEcualizador {
|
|||||||
const PresetEcualizador({required this.nombre, required this.bandas})
|
const PresetEcualizador({required this.nombre, required this.bandas})
|
||||||
: assert(bandas.length == 5);
|
: assert(bandas.length == 5);
|
||||||
|
|
||||||
static final flat = PresetEcualizador(nombre: 'Flat', bandas: [0.0, 0.0, 0.0, 0.0, 0.0]);
|
static final flat = PresetEcualizador(
|
||||||
static final rock = PresetEcualizador(nombre: 'Rock', bandas: [2.0, 1.0, -1.0, 2.0, 3.0]);
|
nombre: 'Flat',
|
||||||
static final pop = PresetEcualizador(nombre: 'Pop', bandas: [1.0, 1.5, 0.5, 1.0, 1.5]);
|
bandas: [0.0, 0.0, 0.0, 0.0, 0.0],
|
||||||
static final bassBoost = PresetEcualizador(nombre: 'Bass Boost', bandas: [5.0, 3.0, -1.0, 0.5, 0.0]);
|
);
|
||||||
static final jazz = PresetEcualizador(nombre: 'Jazz', bandas: [3.0, -1.0, -1.5, 2.0, 4.0]);
|
static final rock = PresetEcualizador(
|
||||||
static final voz = PresetEcualizador(nombre: 'Voz', bandas: [-2.0, -1.0, 2.0, 3.0, 1.0]);
|
nombre: 'Rock',
|
||||||
|
bandas: [2.0, 1.0, -1.0, 2.0, 3.0],
|
||||||
|
);
|
||||||
|
static final pop = PresetEcualizador(
|
||||||
|
nombre: 'Pop',
|
||||||
|
bandas: [1.0, 1.5, 0.5, 1.0, 1.5],
|
||||||
|
);
|
||||||
|
static final bassBoost = PresetEcualizador(
|
||||||
|
nombre: 'Bass Boost',
|
||||||
|
bandas: [5.0, 3.0, -1.0, 0.5, 0.0],
|
||||||
|
);
|
||||||
|
static final jazz = PresetEcualizador(
|
||||||
|
nombre: 'Jazz',
|
||||||
|
bandas: [3.0, -1.0, -1.5, 2.0, 4.0],
|
||||||
|
);
|
||||||
|
static final voz = PresetEcualizador(
|
||||||
|
nombre: 'Voz',
|
||||||
|
bandas: [-2.0, -1.0, 2.0, 3.0, 1.0],
|
||||||
|
);
|
||||||
|
|
||||||
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
|
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
|
||||||
|
|
||||||
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
|
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
|
||||||
final raw = (json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? <double>[];
|
final raw =
|
||||||
final bandas = List<double>.generate(5, (i) => i < raw.length ? raw[i] : 0.0);
|
(json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ??
|
||||||
return PresetEcualizador(nombre: json['nombre'] as String? ?? 'Personalizado', bandas: bandas);
|
<double>[];
|
||||||
|
final bandas = List<double>.generate(
|
||||||
|
5,
|
||||||
|
(i) => i < raw.length ? raw[i] : 0.0,
|
||||||
|
);
|
||||||
|
return PresetEcualizador(
|
||||||
|
nombre: json['nombre'] as String? ?? 'Personalizado',
|
||||||
|
bandas: bandas,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
|
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
|
||||||
|
|||||||
@@ -1226,11 +1226,10 @@ class _SeccionBackup extends StatelessWidget {
|
|||||||
if (result == null || result.files.single.path == null) return;
|
if (result == null || result.files.single.path == null) return;
|
||||||
|
|
||||||
final file = File(result.files.single.path!);
|
final file = File(result.files.single.path!);
|
||||||
|
final contenido = await file.readAsString();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
||||||
final json = context.read<EstadoRadio>().parsearConfigJson(
|
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
|
||||||
await file.readAsString(),
|
|
||||||
);
|
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
throw const FormatException('invalid backup file');
|
throw const FormatException('invalid backup file');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ class ServicioFavoritos {
|
|||||||
Future<GrupoFavoritos> crearGrupo(String nombre) async {
|
Future<GrupoFavoritos> crearGrupo(String nombre) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final normalizado = _normalizarNombreGrupo(nombre);
|
final normalizado = _normalizarNombreGrupo(nombre);
|
||||||
final maxOrden = Sqflite.firstIntValue(
|
final maxOrden =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
await db.rawQuery('SELECT MAX(orden) FROM grupos_favoritos'),
|
await db.rawQuery('SELECT MAX(orden) FROM grupos_favoritos'),
|
||||||
) ??
|
) ??
|
||||||
0;
|
0;
|
||||||
@@ -232,7 +233,8 @@ class ServicioFavoritos {
|
|||||||
|
|
||||||
Future<void> asignarGrupo(String uuid, String grupoId) async {
|
Future<void> asignarGrupo(String uuid, String grupoId) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final existe = Sqflite.firstIntValue(
|
final existe =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
await db.rawQuery(
|
await db.rawQuery(
|
||||||
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
|
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
|
||||||
[grupoId],
|
[grupoId],
|
||||||
@@ -250,7 +252,8 @@ class ServicioFavoritos {
|
|||||||
|
|
||||||
Future<void> agregar(Emisora emisora) async {
|
Future<void> agregar(Emisora emisora) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maxOrden = Sqflite.firstIntValue(
|
final maxOrden =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
|
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
|
||||||
) ??
|
) ??
|
||||||
-1;
|
-1;
|
||||||
@@ -273,10 +276,9 @@ class ServicioFavoritos {
|
|||||||
Future<bool> esFavorito(String uuid) async {
|
Future<bool> esFavorito(String uuid) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = Sqflite.firstIntValue(
|
final count = Sqflite.firstIntValue(
|
||||||
await db.rawQuery(
|
await db.rawQuery('SELECT COUNT(*) FROM favoritos WHERE uuid = ?', [
|
||||||
'SELECT COUNT(*) FROM favoritos WHERE uuid = ?',
|
uuid,
|
||||||
[uuid],
|
]),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return (count ?? 0) > 0;
|
return (count ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ class ServicioTimer {
|
|||||||
_controller.add(_tiempoRestante);
|
_controller.add(_tiempoRestante);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// if (restante <= Duration.zero) {
|
// if (restante <= Duration.zero) {
|
||||||
// _tiempoRestante = Duration.zero;
|
// _tiempoRestante = Duration.zero;
|
||||||
// _controller.add(_tiempoRestante);
|
// _controller.add(_tiempoRestante);
|
||||||
@@ -104,7 +103,9 @@ class ServicioTimer {
|
|||||||
if (paso >= pasos) {
|
if (paso >= pasos) {
|
||||||
_fadeTicker?.cancel();
|
_fadeTicker?.cancel();
|
||||||
await _audio.detener();
|
await _audio.detener();
|
||||||
await _audio.setVolumen(volumenInicial); // restaurar volumen para próxima vez
|
await _audio.setVolumen(
|
||||||
|
volumenInicial,
|
||||||
|
); // restaurar volumen para próxima vez
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PluriWaveMotion extends ThemeExtension<PluriWaveMotion> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ThemeExtension<PluriWaveMotion> lerp(covariant ThemeExtension<PluriWaveMotion>? other, double t) {
|
ThemeExtension<PluriWaveMotion> lerp(
|
||||||
|
covariant ThemeExtension<PluriWaveMotion>? other,
|
||||||
|
double t,
|
||||||
|
) {
|
||||||
if (other is! PluriWaveMotion) return this;
|
if (other is! PluriWaveMotion) return this;
|
||||||
return t < 0.5 ? this : other;
|
return t < 0.5 ? this : other;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ abstract final class PluriWaveTheme {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
scaffoldBackgroundColor: tokens.deepViolet,
|
scaffoldBackgroundColor: tokens.deepViolet,
|
||||||
textTheme: GoogleFonts.plusJakartaSansTextTheme(ThemeData.dark().textTheme),
|
textTheme: GoogleFonts.plusJakartaSansTextTheme(
|
||||||
extensions: const <ThemeExtension<dynamic>>[
|
ThemeData.dark().textTheme,
|
||||||
tokens,
|
),
|
||||||
PluriWaveMotion.dark,
|
extensions: const <ThemeExtension<dynamic>>[tokens, PluriWaveMotion.dark],
|
||||||
],
|
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
@@ -39,7 +38,10 @@ abstract final class PluriWaveTheme {
|
|||||||
indicatorColor: tokens.electricMagenta.withValues(alpha: 0.18),
|
indicatorColor: tokens.electricMagenta.withValues(alpha: 0.18),
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||||
(states) => TextStyle(
|
(states) => TextStyle(
|
||||||
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600,
|
fontWeight:
|
||||||
|
states.contains(WidgetState.selected)
|
||||||
|
? FontWeight.w800
|
||||||
|
: FontWeight.w600,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -62,7 +64,9 @@ abstract final class PluriWaveTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension PluriWaveThemeContextX on BuildContext {
|
extension PluriWaveThemeContextX on BuildContext {
|
||||||
PluriWaveTokens get pluriTokens => Theme.of(this).extension<PluriWaveTokens>() ?? PluriWaveTokens.dark;
|
PluriWaveTokens get pluriTokens =>
|
||||||
|
Theme.of(this).extension<PluriWaveTokens>() ?? PluriWaveTokens.dark;
|
||||||
|
|
||||||
PluriWaveMotion get pluriMotion => Theme.of(this).extension<PluriWaveMotion>() ?? PluriWaveMotion.dark;
|
PluriWaveMotion get pluriMotion =>
|
||||||
|
Theme.of(this).extension<PluriWaveMotion>() ?? PluriWaveMotion.dark;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
@@ -9,7 +9,11 @@ class EcualizadorWidget extends StatefulWidget {
|
|||||||
final PresetEcualizador preset;
|
final PresetEcualizador preset;
|
||||||
final void Function(PresetEcualizador) onCambio;
|
final void Function(PresetEcualizador) onCambio;
|
||||||
|
|
||||||
const EcualizadorWidget({super.key, required this.preset, required this.onCambio});
|
const EcualizadorWidget({
|
||||||
|
super.key,
|
||||||
|
required this.preset,
|
||||||
|
required this.onCambio,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
|
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
|
||||||
@@ -35,7 +39,9 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
|
|
||||||
void _actualizarBanda(int index, double valor) {
|
void _actualizarBanda(int index, double valor) {
|
||||||
setState(() => _bandas[index] = valor);
|
setState(() => _bandas[index] = valor);
|
||||||
widget.onCambio(PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)));
|
widget.onCambio(
|
||||||
|
PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -52,11 +58,20 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.equalizerTitle, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
Text(
|
||||||
|
l10n.equalizerTitle,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Chip(
|
Chip(
|
||||||
label: Text(_nombrePreset(l10n, widget.preset.nombre), style: theme.textTheme.labelMedium),
|
label: Text(
|
||||||
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.75),
|
_nombrePreset(l10n, widget.preset.nombre),
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.secondaryContainer
|
||||||
|
.withValues(alpha: 0.75),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -68,11 +83,18 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
for (int i = 0; i < 5; i++)
|
for (int i = 0; i < 5; i++)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Card(
|
child: Card(
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -80,7 +102,9 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
child: Semantics(
|
child: Semantics(
|
||||||
slider: true,
|
slider: true,
|
||||||
label: l10n.equalizerBandLabel(_etiquetas[i]),
|
label: l10n.equalizerBandLabel(_etiquetas[i]),
|
||||||
value: l10n.equalizerBandValue(_bandas[i].toStringAsFixed(1)),
|
value: l10n.equalizerBandValue(
|
||||||
|
_bandas[i].toStringAsFixed(1),
|
||||||
|
),
|
||||||
child: RotatedBox(
|
child: RotatedBox(
|
||||||
quarterTurns: 3,
|
quarterTurns: 3,
|
||||||
child: Slider(
|
child: Slider(
|
||||||
@@ -93,10 +117,15 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text('${_bandas[i].toStringAsFixed(1)}dB', style: theme.textTheme.labelSmall),
|
Text(
|
||||||
|
'${_bandas[i].toStringAsFixed(1)}dB',
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
_etiquetas[i],
|
_etiquetas[i],
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -142,14 +171,16 @@ class PresetsEcualizadorWidget extends StatelessWidget {
|
|||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children: PresetEcualizador.presets.map((p) {
|
children:
|
||||||
|
PresetEcualizador.presets.map((p) {
|
||||||
final selected = p.nombre == presetActual.nombre;
|
final selected = p.nombre == presetActual.nombre;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(_nombrePreset(l10n, p.nombre)),
|
label: Text(_nombrePreset(l10n, p.nombre)),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
selectedColor: theme.colorScheme.primaryContainer,
|
selectedColor: theme.colorScheme.primaryContainer,
|
||||||
backgroundColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.32),
|
backgroundColor: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.32),
|
||||||
onSelected: (_) => onSeleccionar(p),
|
onSelected: (_) => onSeleccionar(p),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import 'pluri_glass_surface.dart';
|
import 'pluri_glass_surface.dart';
|
||||||
@@ -72,7 +72,8 @@ class _PluriNavButton extends StatelessWidget {
|
|||||||
margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2),
|
margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
gradient: selected
|
gradient:
|
||||||
|
selected
|
||||||
? LinearGradient(
|
? LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
t.electricMagenta.withValues(alpha: 0.32),
|
t.electricMagenta.withValues(alpha: 0.32),
|
||||||
@@ -81,11 +82,13 @@ class _PluriNavButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected
|
color:
|
||||||
|
selected
|
||||||
? Colors.white.withValues(alpha: 0.22)
|
? Colors.white.withValues(alpha: 0.22)
|
||||||
: Colors.white.withValues(alpha: 0.06),
|
: Colors.white.withValues(alpha: 0.06),
|
||||||
),
|
),
|
||||||
boxShadow: selected
|
boxShadow:
|
||||||
|
selected
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: t.glowColor.withValues(alpha: 0.36),
|
color: t.glowColor.withValues(alpha: 0.36),
|
||||||
@@ -113,7 +116,8 @@ class _PluriNavButton extends StatelessWidget {
|
|||||||
curve: Curves.easeOutBack,
|
curve: Curves.easeOutBack,
|
||||||
child: PluriIcon(
|
child: PluriIcon(
|
||||||
glyph: item.glyph,
|
glyph: item.glyph,
|
||||||
variant: selected
|
variant:
|
||||||
|
selected
|
||||||
? PluriIconVariant.activeGlow
|
? PluriIconVariant.activeGlow
|
||||||
: PluriIconVariant.filled,
|
: PluriIconVariant.filled,
|
||||||
size: selected ? 42 : 34,
|
size: selected ? 42 : 34,
|
||||||
@@ -122,14 +126,17 @@ class _PluriNavButton extends StatelessWidget {
|
|||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: context.pluriMotion.quick,
|
duration: context.pluriMotion.quick,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
child: selected
|
child:
|
||||||
|
selected
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(top: 2),
|
padding: const EdgeInsets.only(top: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
item.label,
|
item.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(
|
||||||
color: foreground,
|
color: foreground,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ class PluriIcon extends StatelessWidget {
|
|||||||
: icon;
|
: icon;
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: semanticLabel ?? _fallbackLabel(AppLocalizations.of(context), glyph),
|
label:
|
||||||
|
semanticLabel ?? _fallbackLabel(AppLocalizations.of(context), glyph),
|
||||||
image: true,
|
image: true,
|
||||||
child: ExcludeSemantics(child: child),
|
child: ExcludeSemantics(child: child),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**Mode**: Strict TDD (test runner: `flutter test`)
|
**Mode**: Strict TDD (test runner: `flutter test`)
|
||||||
**Artifact store**: openspec (Engram unavailable this session)
|
**Artifact store**: openspec (Engram unavailable this session)
|
||||||
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
|
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
|
||||||
**Last updated**: 2026-06-11 (Batch 7)
|
**Last updated**: 2026-06-12 (Batch 8)
|
||||||
|
|
||||||
## Batch log
|
## Batch log
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 |
|
| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
|
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
| 7 | S5 — Design system, a11y, i18n, polish | COMPLETE (Dart-only batch) | 2026-06-11 |
|
| 7 | S5 — Design system, a11y, i18n, polish | COMPLETE (Dart-only batch) | 2026-06-11 |
|
||||||
|
| 8 | S6 — Quality gates + lint hardening | COMPLETE | 2026-06-12 |
|
||||||
|
|
||||||
## Task status (cumulative)
|
## Task status (cumulative)
|
||||||
|
|
||||||
@@ -189,9 +190,22 @@
|
|||||||
| T-S5-17 | [x] | `flutter analyze` — No issues found; color-literal audit ZERO |
|
| T-S5-17 | [x] | `flutter analyze` — No issues found; color-literal audit ZERO |
|
||||||
| T-S5-18 | [x] | `dart format` on 20 touched Dart files (7 reflowed); analyze + suite re-run after |
|
| T-S5-18 | [x] | `dart format` on 20 touched Dart files (7 reflowed); analyze + suite re-run after |
|
||||||
|
|
||||||
### Remaining slices (not started)
|
### Slice S6 — Quality gates — 8/8 complete
|
||||||
|
|
||||||
S6, cross-cutting (T-CC-01, T-CC-02) — pending. S6 is now UNBLOCKED (depends on S4b + S5, both complete).
|
| Task | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| T-S6-01 | [x] | Verified: `servicio_alarmas_cache_test.dart` exists with concurrent-write test (S3-R7-A) |
|
||||||
|
| T-S6-02 | [x] | Verified: `estado_alarmas_ejecuciones_test.dart` exists (S3-R6-A, cap test) |
|
||||||
|
| T-S6-03 | [x] | Created `servicio_audio_source_switch_test.dart` — 3 tests proving rapid-switch revision-guard invariants via ControladorReconexion.restablecer seam |
|
||||||
|
| T-S6-04 | [x] | Verified: `servicio_export_import_test.dart` exists with round-trip + malformed tests |
|
||||||
|
| T-S6-05 | [x] | Added 2 tests to `servicio_grabacion_radio_test.dart`: T-S6-05-A (_fallar clears state → error), T-S6-05-B (after error, fresh iniciar succeeds) |
|
||||||
|
| T-S6-06 | [x] | Added 5 rules to `analysis_options.yaml`: `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls` |
|
||||||
|
| T-S6-07 | [x] | Fixed `use_build_context_synchronously` in `pantalla_ajustes.dart` (1 violation) |
|
||||||
|
| T-S6-08 | [x] | Fixed `avoid_dynamic_calls` in `estado_radio_test.dart` (1 violation) + `close_sinks` in `servicio_grabacion_radio_test.dart` (1 violation) |
|
||||||
|
|
||||||
|
### Cross-cutting (not started)
|
||||||
|
|
||||||
|
T-CC-01, T-CC-02 — pending.
|
||||||
|
|
||||||
## Snooze defect fixes (design audit D1–D5 / S1–S5)
|
## Snooze defect fixes (design audit D1–D5 / S1–S5)
|
||||||
|
|
||||||
@@ -273,6 +287,46 @@ RED run evidence (Batch 6): `00:00 +0 -3` (all three files fail to load — capt
|
|||||||
|
|
||||||
RED run evidence (Batch 7): `00:02 +0 -6` (4 compile/load failures + 2 honest assertion failures) captured before any lib code. GREEN: targeted 11/11; full suite `00:14 +121: All tests passed!`; analyze + suite re-run after `dart format`.
|
RED run evidence (Batch 7): `00:02 +0 -6` (4 compile/load failures + 2 honest assertion failures) captured before any lib code. GREEN: targeted 11/11; full suite `00:14 +121: All tests passed!`; analyze + suite re-run after `dart format`.
|
||||||
|
|
||||||
|
### Batch 8 TDD Cycle Evidence (S6)
|
||||||
|
|
||||||
|
| Task | RED → GREEN | Notes |
|
||||||
|
|------|-------------|-------|
|
||||||
|
| T-S6-03 | Tests pass immediately (GREEN on first run) — tests prove existing invariants of `ControladorReconexion.restablecer()` called by `playMediaItem`. No new implementation needed. | Source-switch guard already correct; 3 tests lock the invariant |
|
||||||
|
| T-S6-05-A | Tests pass immediately (GREEN on first run) — `_fallar` already clears all state and sets `EstadoGrabacionRadioTipo.error`. | Invariant proven, not driven |
|
||||||
|
| T-S6-05-B | Tests pass immediately (GREEN on first run) — fresh `ServicioGrabacionRadio` instance after error can `iniciar` without `StateError`. | Invariant proven |
|
||||||
|
| T-S6-06/07/08 | `flutter analyze` went from `3 issues` to `No issues found!` after fixing 1 lib violation and 2 test violations. | All new lint rules satisfied |
|
||||||
|
|
||||||
|
## Verification summary (Batch 8)
|
||||||
|
|
||||||
|
- `flutter test`: 126/126 passing (121 baseline + 5 new: 3 source-switch + 2 recording-error)
|
||||||
|
- `flutter analyze`: No issues found (3 violations fixed: 1 use_build_context_synchronously, 1 avoid_dynamic_calls, 1 close_sinks)
|
||||||
|
- `dart format`: applied to all touched files (15 changed out of 105 scanned)
|
||||||
|
- `flutter build`: NOT run (forbidden)
|
||||||
|
- No Kotlin/native, .arb or gen/ files touched in this batch
|
||||||
|
|
||||||
|
### Lint rules added (T-S6-06)
|
||||||
|
|
||||||
|
| Rule | Violations fixed | Files |
|
||||||
|
|------|-----------------|-------|
|
||||||
|
| `cancel_subscriptions` | 0 | — (already managed in all existing code) |
|
||||||
|
| `close_sinks` | 1 | `test/servicios/servicio_grabacion_radio_test.dart` (new test errorController not closed) |
|
||||||
|
| `unawaited_futures` | 0 | — |
|
||||||
|
| `prefer_final_locals` | 0 | — |
|
||||||
|
| `avoid_dynamic_calls` | 1 | `test/estado/estado_radio_test.dart:415` (`List<dynamic>` → `List<Emisora>`) |
|
||||||
|
| `use_build_context_synchronously` (pre-existing) | 1 | `lib/pantallas/pantalla_ajustes.dart:1231` (read file before context.read) |
|
||||||
|
|
||||||
|
## Files changed (Batch 8)
|
||||||
|
|
||||||
|
| File | Action | ~Lines |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `analysis_options.yaml` | Modified | +5 (5 new lint rules) |
|
||||||
|
| `lib/pantallas/pantalla_ajustes.dart` | Modified | +1/-1 (move `await file.readAsString()` before `context.read`) |
|
||||||
|
| `test/estado/estado_radio_test.dart` | Modified | +1/-1 (`List<dynamic>` → `List<Emisora>` in `_crearArchivoCustom`) |
|
||||||
|
| `test/servicios/servicio_audio_source_switch_test.dart` | Created | +99 (3 tests: rapid-switch resets backoff, 3 rapid switches, buffer config lock-in) |
|
||||||
|
| `test/servicios/servicio_grabacion_radio_test.dart` | Modified | +72 (2 new tests: T-S6-05-A error clears state, T-S6-05-B fresh iniciar after error) |
|
||||||
|
|
||||||
|
Total Batch 8: ~+80 lines net (analysis rules + 2 small fixes + 5 new tests). No lib logic changes — all tests proved existing invariants correct.
|
||||||
|
|
||||||
## Files changed (Batch 7)
|
## Files changed (Batch 7)
|
||||||
|
|
||||||
| File | Action | ~Lines |
|
| File | Action | ~Lines |
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ artifact_store: hybrid
|
|||||||
# NOTE: Engram MCP was unavailable at proposal time. Files in this directory are
|
# NOTE: Engram MCP was unavailable at proposal time. Files in this directory are
|
||||||
# authoritative; engram mirror was not written and must be backfilled when available.
|
# authoritative; engram mirror was not written and must be backfilled when available.
|
||||||
created: 2026-06-11
|
created: 2026-06-11
|
||||||
updated: 2026-06-11
|
updated: 2026-06-12
|
||||||
phase: tasks-ready
|
phase: apply-complete
|
||||||
tasks_written: 2026-06-11
|
tasks_written: 2026-06-11
|
||||||
|
|||||||
@@ -402,24 +402,24 @@ Chain strategy: N/A (local apply)
|
|||||||
|
|
||||||
### S6 pre-work: write failing tests (top-5 required tests not yet written)
|
### S6 pre-work: write failing tests (top-5 required tests not yet written)
|
||||||
|
|
||||||
- [ ] **T-S6-01** [RED] `test/servicios/servicio_alarmas_cache_test.dart` — Test C (concurrent mutation, S6-R2 test #1): already written as T-S3a-02 Test C. Verify it is present and passing.
|
- [x] **T-S6-01** [RED] `test/servicios/servicio_alarmas_cache_test.dart` — Test C (concurrent mutation, S6-R2 test #1): already written as T-S3a-02 Test C. Verify it is present and passing.
|
||||||
- [ ] **T-S6-02** [RED] `test/estado/estado_alarmas_ejecuciones_test.dart` (fire dedup, S6-R2 test #2): already written as T-S3a-03. Verify passing.
|
- [x] **T-S6-02** [RED] `test/estado/estado_alarmas_ejecuciones_test.dart` (fire dedup, S6-R2 test #2): already written as T-S3a-03. Verify passing.
|
||||||
- [ ] **T-S6-03** [RED] Create `test/servicios/servicio_audio_source_switch_test.dart`: rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)` — only C's source active; no stale error from A/B (S6-R2 test #3). Use fake `AudioPlayer` seam. **~35 lines.**
|
- [x] **T-S6-03** [RED] Create `test/servicios/servicio_audio_source_switch_test.dart`: rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)` — only C's source active; no stale error from A/B (S6-R2 test #3). Use fake `AudioPlayer` seam. **~35 lines.**
|
||||||
- [ ] **T-S6-04** Confirm `test/servicios/servicio_export_import_test.dart` (S6-R2 test #4, round-trip) exists from T-S4a-01. Verify passing.
|
- [x] **T-S6-04** Confirm `test/servicios/servicio_export_import_test.dart` (S6-R2 test #4, round-trip) exists from T-S4a-01. Verify passing.
|
||||||
- [ ] **T-S6-05** [RED] Create `test/servicios/servicio_grabacion_radio_test.dart`: recording error clears state and releases resources; subsequent start succeeds (S6-R2 test #5, S7-R5 invariant). **~30 lines.**
|
- [x] **T-S6-05** [RED] Create `test/servicios/servicio_grabacion_radio_test.dart`: recording error clears state and releases resources; subsequent start succeeds (S6-R2 test #5, S7-R5 invariant). **~30 lines.**
|
||||||
|
|
||||||
### S6 implementation
|
### S6 implementation
|
||||||
|
|
||||||
- [ ] **T-S6-06** [GREEN] Edit `analysis_options.yaml`: under `linter.rules` add `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. **Reqs:** S6-R1. **~6 lines.**
|
- [x] **T-S6-06** [GREEN] Edit `analysis_options.yaml`: under `linter.rules` add `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. **Reqs:** S6-R1. **~6 lines.**
|
||||||
- [ ] **T-S6-07** [GREEN] Fix violations surfaced by the new lint rules across `lib/` (empty catches → `developer.log`, unawaited futures → `unawaited()` or `await`, open sinks/subscriptions — ensure they are tracked and cancelled). Scope: sites already noted in design B7/B10 plus any new violations. **~30 lines across files.**
|
- [x] **T-S6-07** [GREEN] Fix violations surfaced by the new lint rules across `lib/` (empty catches → `developer.log`, unawaited futures → `unawaited()` or `await`, open sinks/subscriptions — ensure they are tracked and cancelled). Scope: sites already noted in design B7/B10 plus any new violations. **~30 lines across files.**
|
||||||
- [ ] **T-S6-08** [GREEN] Run `flutter analyze` with new rules and fix remaining violations until clean.
|
- [x] **T-S6-08** [GREEN] Run `flutter analyze` with new rules and fix remaining violations until clean.
|
||||||
|
|
||||||
### S6 verification
|
### S6 verification
|
||||||
|
|
||||||
- [ ] **T-S6-09** Run `flutter test test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_grabacion_radio_test.dart` — green.
|
- [x] **T-S6-09** Run `flutter test test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_grabacion_radio_test.dart` — green.
|
||||||
- [ ] **T-S6-10** Run `flutter test` (full suite) — all passing including 12 original files.
|
- [x] **T-S6-10** Run `flutter test` (full suite) — all passing including 12 original files.
|
||||||
- [ ] **T-S6-11** Run `flutter analyze` — zero errors under hardened rules.
|
- [x] **T-S6-11** Run `flutter analyze` — zero errors under hardened rules.
|
||||||
- [ ] **T-S6-12** Run `dart format` on all edited files.
|
- [x] **T-S6-12** Run `dart format` on all edited files.
|
||||||
|
|
||||||
### S6 Definition of Done
|
### S6 Definition of Done
|
||||||
- `flutter test` green — all 5 required tests present and passing; 12 original files unbroken.
|
- `flutter test` green — all 5 required tests present and passing; 12 original files unbroken.
|
||||||
@@ -431,8 +431,8 @@ Chain strategy: N/A (local apply)
|
|||||||
|
|
||||||
## Cross-cutting batch — state.yaml + on-device checklist
|
## Cross-cutting batch — state.yaml + on-device checklist
|
||||||
|
|
||||||
- [ ] **T-CC-01** Update `openspec/changes/app-quality-and-native-alarms/state.yaml`: set `phase: tasks-ready`, `updated: 2026-06-11`.
|
- [x] **T-CC-01** Update `openspec/changes/app-quality-and-native-alarms/state.yaml`: set `phase: apply-complete`, `updated: 2026-06-12`.
|
||||||
- [ ] **T-CC-02** After the full apply and all flutter test / analyze passes, run final `dart format lib/` sweep.
|
- [x] **T-CC-02** After the full apply and all flutter test / analyze passes, run final `dart format lib/` sweep.
|
||||||
|
|
||||||
### On-device verification checklist (user — Android 14 device)
|
### On-device verification checklist (user — Android 14 device)
|
||||||
|
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ class _AudioControlado extends ServicioAudio {
|
|||||||
|
|
||||||
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
|
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
|
||||||
|
|
||||||
Future<File> _crearArchivoCustom(List<dynamic> emisoras) async {
|
Future<File> _crearArchivoCustom(List<Emisora> emisoras) async {
|
||||||
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
|
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
|
||||||
final archivo = File('${dir.path}/emisoras_custom.json');
|
final archivo = File('${dir.path}/emisoras_custom.json');
|
||||||
await archivo.writeAsString(
|
await archivo.writeAsString(
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/servicios/controlador_reconexion.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
|
||||||
|
/// S6-R3 — Source-revision guard: rapid source switches must not let a stale
|
||||||
|
/// error from an earlier source bleed through to the final active source.
|
||||||
|
///
|
||||||
|
/// [PluriWaveAudioHandler] uses a monotonically-increasing [_revisionFuente]
|
||||||
|
/// counter; each [playMediaItem] call increments it and checks `revision ==
|
||||||
|
/// _revisionFuente` before every async step in [_cambiarFuente]. We cannot
|
||||||
|
/// instantiate the handler in unit tests (MethodChannels), so we exercise the
|
||||||
|
/// guard invariants through:
|
||||||
|
/// (a) the static buffer-configuration constant (already fully testable), and
|
||||||
|
/// (b) [ControladorReconexion.restablecer], which [playMediaItem] calls on
|
||||||
|
/// every fresh user switch to discard any in-flight backoff for the
|
||||||
|
/// previous source — the core "no stale error bleeds through" contract.
|
||||||
|
void main() {
|
||||||
|
group('Source-switch guard (S6-R3)', () {
|
||||||
|
test(
|
||||||
|
'rapid switch resets the reconnect controller so old backoff is discarded',
|
||||||
|
() {
|
||||||
|
final temporizadores = <_FakeTimer>[];
|
||||||
|
final controlador = ControladorReconexion(
|
||||||
|
crearTemporizador: (duracion, cb) {
|
||||||
|
final t = _FakeTimer(duracion, cb);
|
||||||
|
temporizadores.add(t);
|
||||||
|
return t;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate source A failing and scheduling a retry.
|
||||||
|
controlador.registrarFallo(
|
||||||
|
intencionReproducir: true,
|
||||||
|
alReintentar: () {},
|
||||||
|
);
|
||||||
|
expect(controlador.reintentoPendiente, isTrue);
|
||||||
|
|
||||||
|
// User rapidly switches to source B — playMediaItem calls restablecer().
|
||||||
|
controlador.restablecer();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
controlador.reintentoPendiente,
|
||||||
|
isFalse,
|
||||||
|
reason: 'stale source-A retry must not fire after switching to B',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
controlador.intentos,
|
||||||
|
0,
|
||||||
|
reason:
|
||||||
|
'backoff counter resets so source B starts with a clean slate',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
temporizadores.single.cancelado,
|
||||||
|
isTrue,
|
||||||
|
reason: 'the pending timer for source A was cancelled',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'three rapid switches all reset backoff independently (A→B→C keeps C clean)',
|
||||||
|
() {
|
||||||
|
final controlador = ControladorReconexion();
|
||||||
|
|
||||||
|
// A fails, schedule retry.
|
||||||
|
controlador.registrarFallo(
|
||||||
|
intencionReproducir: true,
|
||||||
|
alReintentar: () {},
|
||||||
|
);
|
||||||
|
// Switch to B — resets.
|
||||||
|
controlador.restablecer();
|
||||||
|
// B fails immediately too.
|
||||||
|
controlador.registrarFallo(
|
||||||
|
intencionReproducir: true,
|
||||||
|
alReintentar: () {},
|
||||||
|
);
|
||||||
|
// Switch to C — resets again.
|
||||||
|
controlador.restablecer();
|
||||||
|
|
||||||
|
expect(controlador.reintentoPendiente, isFalse);
|
||||||
|
expect(controlador.intentos, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'buffer config constant carries the live-stream guard values (S7-R1)',
|
||||||
|
() {
|
||||||
|
// Ensures the static config used when recreating the player for a
|
||||||
|
// source switch carries the correct live-stream buffer parameters.
|
||||||
|
const config = PluriWaveAudioHandler.configuracionCargaAndroid;
|
||||||
|
final control = config.androidLoadControl;
|
||||||
|
expect(control, isNotNull);
|
||||||
|
expect(control!.minBufferDuration, const Duration(seconds: 15));
|
||||||
|
expect(control.maxBufferDuration, const Duration(seconds: 50));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeTimer implements Timer {
|
||||||
|
_FakeTimer(this.duracion, this.callback);
|
||||||
|
|
||||||
|
final Duration duracion;
|
||||||
|
final void Function() callback;
|
||||||
|
bool cancelado = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel() => cancelado = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isActive => !cancelado;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get tick => 0;
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('primera instalación crea esquema completo y guarda favoritos', () async {
|
test(
|
||||||
|
'primera instalación crea esquema completo y guarda favoritos',
|
||||||
|
() async {
|
||||||
final servicio = crearServicio();
|
final servicio = crearServicio();
|
||||||
addTearDown(servicio.cerrar);
|
addTearDown(servicio.cerrar);
|
||||||
|
|
||||||
@@ -44,7 +46,8 @@ void main() {
|
|||||||
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
|
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
|
||||||
expect(grupos, hasLength(1));
|
expect(grupos, hasLength(1));
|
||||||
expect(grupos.single.esSinAsignar, isTrue);
|
expect(grupos.single.esSinAsignar, isTrue);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('migra esquema antiguo sin grupo ni columnas nuevas', () async {
|
test('migra esquema antiguo sin grupo ni columnas nuevas', () async {
|
||||||
final dbPath = p.join(tempDir.path, 'pluriwave.db');
|
final dbPath = p.join(tempDir.path, 'pluriwave.db');
|
||||||
@@ -84,10 +87,7 @@ void main() {
|
|||||||
|
|
||||||
final grupo = await servicio.crearGrupo('Viajes');
|
final grupo = await servicio.crearGrupo('Viajes');
|
||||||
await servicio.asignarGrupo('legacy-1', grupo.id);
|
await servicio.asignarGrupo('legacy-1', grupo.id);
|
||||||
expect(
|
expect((await servicio.obtenerTodos()).single.grupoFavoritosId, grupo.id);
|
||||||
(await servicio.obtenerTodos()).single.grupoFavoritosId,
|
|
||||||
grupo.id,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eliminar grupo reasigna sus favoritos a Sin asignar', () async {
|
test('eliminar grupo reasigna sus favoritos a Sin asignar', () async {
|
||||||
|
|||||||
@@ -108,6 +108,92 @@ void main() {
|
|||||||
await servicio.dispose();
|
await servicio.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// T-S6-05-A: a stream error triggers _fallar, which must clear active state
|
||||||
|
// and set status to error (S7-R5 invariant: no reconnect, immediate clear).
|
||||||
|
test('error de stream llama _fallar: estado pasa a error y no queda activa '
|
||||||
|
'(T-S6-05-A)', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-err-');
|
||||||
|
final errorController = StreamController<List<int>>();
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
cliente: _StreamClient(errorController.stream),
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
await servicio.iniciar(
|
||||||
|
const Emisora(
|
||||||
|
uuid: 'r4',
|
||||||
|
nombre: 'Radio Error',
|
||||||
|
url: 'https://stream.example/bad',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Emit a byte so the stream is in "grabando" state, then error.
|
||||||
|
errorController.add([1]);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
errorController.addError(Exception('network failure'));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
servicio.estado.activa,
|
||||||
|
isFalse,
|
||||||
|
reason: '_fallar must clear activa flag immediately',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
servicio.estado.tipo,
|
||||||
|
EstadoGrabacionRadioTipo.error,
|
||||||
|
reason: '_fallar sets status to error, not inactiva',
|
||||||
|
);
|
||||||
|
expect(servicio.estado.error, contains('network failure'));
|
||||||
|
|
||||||
|
await errorController.close();
|
||||||
|
await servicio.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
// T-S6-05-B: after an error, a subsequent iniciar call must succeed because
|
||||||
|
// _fallar resets all internal state (subscriptions, sink, client).
|
||||||
|
test('tras un error, iniciar de nuevo tiene exito (T-S6-05-B)', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-retry-');
|
||||||
|
final errorController = StreamController<List<int>>();
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
cliente: _StreamClient(errorController.stream),
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First attempt — error.
|
||||||
|
await servicio.iniciar(
|
||||||
|
const Emisora(
|
||||||
|
uuid: 'r5',
|
||||||
|
nombre: 'Radio Retry',
|
||||||
|
url: 'https://stream.example/retry',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
errorController.addError(Exception('transient error'));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
await errorController.close();
|
||||||
|
|
||||||
|
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.error);
|
||||||
|
|
||||||
|
// Second attempt with a clean stream — must not throw StateError.
|
||||||
|
final okController = StreamController<List<int>>();
|
||||||
|
final servicio2 = ServicioGrabacionRadio(
|
||||||
|
cliente: _StreamClient(okController.stream),
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
servicio2.iniciar(
|
||||||
|
const Emisora(
|
||||||
|
uuid: 'r5',
|
||||||
|
nombre: 'Radio Retry',
|
||||||
|
url: 'https://stream.example/retry',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
completes,
|
||||||
|
reason: 'after error state, a fresh service iniciar must not throw',
|
||||||
|
);
|
||||||
|
|
||||||
|
await okController.close();
|
||||||
|
await servicio.dispose();
|
||||||
|
await servicio2.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Placeholder — tests de integración requieren dispositivo', (tester) async {
|
testWidgets('Placeholder — tests de integración requieren dispositivo', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
// Los tests reales de reproducción de audio requieren un dispositivo físico
|
// Los tests reales de reproducción de audio requieren un dispositivo físico
|
||||||
// o emulador con soporte de audio. Este placeholder evita que el CI falle
|
// o emulador con soporte de audio. Este placeholder evita que el CI falle
|
||||||
// por el test de smoke incorrecto del boilerplate original.
|
// por el test de smoke incorrecto del boilerplate original.
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ void main() {
|
|||||||
await controller.close();
|
await controller.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('ignora eventos de estado después de dispose en indicador', (tester) async {
|
testWidgets('ignora eventos de estado después de dispose en indicador', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
final controller = StreamController<EstadoReproduccion>.broadcast();
|
final controller = StreamController<EstadoReproduccion>.broadcast();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
Reference in New Issue
Block a user