mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-17 20:29:30 +02:00
Le serveur n'a qu'une boucle d'événements. La décompression et le parsing des dumps XMLTV (7000+ chaînes, ~90 Mo) la gelaient pendant plusieurs secondes : plus rien ne répondait, y compris le relais turbo.ts. La lecture se coupait donc au démarrage, au moment où l'écran des chaînes demandait le guide (EPG à 19-26 s dans les logs). - Téléchargement inchangé, puis gunzip + utf8 + parse dans Isolate.run - _parse devient statique (aucune capture de this, client HTTP non transférable) - Test : la boucle d'événements ne doit pas geler plus de 250 ms pendant l'indexation d'un dump de taille réelle (1,65 s avant) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
7.9 KiB
Dart
234 lines
7.9 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.dart';
|
||
import 'package:test/test.dart';
|
||
import '../services/xmltv_epg_service.dart';
|
||
|
||
/// Extrait représentatif d'un dump XMLTV public : décalage horaire explicite,
|
||
/// identifiant ponctué côté source là où le panneau annonce `France2.fr`, et
|
||
/// un `display-name` distinct de l'identifiant.
|
||
String _fixture(DateTime start, DateTime stop) {
|
||
String stamp(DateTime d) {
|
||
final u = d.toUtc();
|
||
String two(int v) => v.toString().padLeft(2, '0');
|
||
return '${u.year}${two(u.month)}${two(u.day)}'
|
||
'${two(u.hour)}${two(u.minute)}${two(u.second)} +0000';
|
||
}
|
||
|
||
return '''
|
||
<?xml version="1.0" encoding="utf-8" ?>
|
||
<tv>
|
||
<channel id="France.2.fr"><display-name>FR - FRANCE 2</display-name></channel>
|
||
<programme start="${stamp(start)}" stop="${stamp(stop)}" channel="France.2.fr">
|
||
<title lang="fr">Journal de 20h</title>
|
||
<desc lang="fr">L'info du soir</desc>
|
||
</programme>
|
||
</tv>
|
||
''';
|
||
}
|
||
|
||
void main() {
|
||
final service = XmltvEpgService(sourceUrls: const []);
|
||
|
||
group('normalizeKey', () {
|
||
test('rapproche les identifiants ponctués différemment', () {
|
||
// Le cœur du repli : sans cette normalisation, `France2.fr` du panneau
|
||
// et `France.2.fr` du dump ne se rencontrent jamais.
|
||
expect(
|
||
XmltvEpgService.normalizeKey('France2.fr'),
|
||
XmltvEpgService.normalizeKey('France.2.fr'),
|
||
);
|
||
});
|
||
|
||
test('replie la casse et les accents', () {
|
||
expect(XmltvEpgService.normalizeKey('Chérie 25'), 'cherie25');
|
||
});
|
||
|
||
test('rend une clé vide sur une entrée nulle ou sans caractère utile', () {
|
||
expect(XmltvEpgService.normalizeKey(null), '');
|
||
expect(XmltvEpgService.normalizeKey('...'), '');
|
||
});
|
||
});
|
||
|
||
group('parseXmltvDate', () {
|
||
test('applique le décalage horaire annoncé', () {
|
||
expect(
|
||
XmltvEpgService.parseXmltvDate('20260811200000 +0200'),
|
||
DateTime.utc(2026, 8, 11, 18),
|
||
);
|
||
});
|
||
|
||
test('traite une date sans décalage comme de l UTC', () {
|
||
expect(
|
||
XmltvEpgService.parseXmltvDate('20260811200000'),
|
||
DateTime.utc(2026, 8, 11, 20),
|
||
);
|
||
});
|
||
|
||
test('rejette une valeur tronquée', () {
|
||
expect(XmltvEpgService.parseXmltvDate('202608'), isNull);
|
||
});
|
||
});
|
||
|
||
group('parse', () {
|
||
test('indexe un programme en cours et décode son titre', () {
|
||
final now = DateTime.now().toUtc();
|
||
final index = service.parseForTest(
|
||
_fixture(
|
||
now.subtract(const Duration(minutes: 10)),
|
||
now.add(const Duration(minutes: 20)),
|
||
),
|
||
);
|
||
|
||
final programmes = index[XmltvEpgService.normalizeKey('France2.fr')];
|
||
expect(programmes, isNotNull);
|
||
expect(programmes!.single.title, 'Journal de 20h');
|
||
expect(programmes.single.description, "L'info du soir");
|
||
});
|
||
|
||
test('rend la chaîne atteignable par son nom affiché', () {
|
||
final now = DateTime.now().toUtc();
|
||
final index = service.parseForTest(
|
||
_fixture(now, now.add(const Duration(minutes: 20))),
|
||
);
|
||
|
||
expect(index[XmltvEpgService.normalizeKey('FR - FRANCE 2')], isNotNull);
|
||
});
|
||
|
||
test('écarte les programmes terminés hors fenêtre de rétention', () {
|
||
final old = DateTime.now().toUtc().subtract(const Duration(days: 2));
|
||
final index = service.parseForTest(
|
||
_fixture(old, old.add(const Duration(minutes: 30))),
|
||
);
|
||
|
||
expect(index, isEmpty);
|
||
});
|
||
});
|
||
|
||
group('ensureFresh', () {
|
||
test('ne retélécharge pas tant que l’index est frais', () async {
|
||
final now = DateTime.now().toUtc();
|
||
var calls = 0;
|
||
final service = XmltvEpgService(
|
||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||
client: MockClient((_) async {
|
||
calls++;
|
||
return http.Response(
|
||
_fixture(now, now.add(const Duration(minutes: 30))),
|
||
200,
|
||
);
|
||
}),
|
||
);
|
||
|
||
await service.ensureFresh();
|
||
await service.ensureFresh();
|
||
|
||
expect(calls, 1);
|
||
expect(service.hasData, isTrue);
|
||
});
|
||
|
||
test('espace les tentatives après un échec', () async {
|
||
// Sans ce recul, une source morte était retéléchargée à chaque
|
||
// consultation du guide : une requête sortante par chaîne affichée.
|
||
var calls = 0;
|
||
final service = XmltvEpgService(
|
||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||
retryBackoff: const Duration(minutes: 15),
|
||
client: MockClient((_) async {
|
||
calls++;
|
||
return http.Response('nope', 500);
|
||
}),
|
||
);
|
||
|
||
await service.ensureFresh();
|
||
await service.ensureFresh();
|
||
await service.ensureFresh();
|
||
|
||
expect(calls, 1);
|
||
expect(service.hasData, isFalse);
|
||
});
|
||
});
|
||
|
||
group('réactivité du serveur', () {
|
||
test('l’indexation ne gèle pas la boucle d’événements', () async {
|
||
// Le serveur n'a qu'un isolate : décoder et parser un dump de
|
||
// plusieurs milliers de chaînes dessus bloquait tout le reste pendant
|
||
// des secondes — y compris le relais `turbo.ts` des flux en cours,
|
||
// d'où des coupures systématiques au démarrage de la lecture.
|
||
final now = DateTime.now().toUtc();
|
||
String stamp(DateTime d) {
|
||
String two(int v) => v.toString().padLeft(2, '0');
|
||
return '${d.year}${two(d.month)}${two(d.day)}'
|
||
'${two(d.hour)}${two(d.minute)}${two(d.second)} +0000';
|
||
}
|
||
|
||
// Volume d'un vrai dump de panneau : ~7000 chaînes, résumés de
|
||
// plusieurs phrases.
|
||
final desc = 'Résumé du programme, avec assez de texte pour peser '
|
||
'comme un vrai guide. ' * 4;
|
||
final xml = StringBuffer('<?xml version="1.0"?><tv>');
|
||
for (var c = 0; c < 7000; c++) {
|
||
xml.write('<channel id="Chaine.$c.fr">'
|
||
'<display-name>FR - CHAINE $c</display-name></channel>');
|
||
for (var p = 0; p < 30; p++) {
|
||
final start = now.add(Duration(minutes: 30 * p));
|
||
final stop = start.add(const Duration(minutes: 30));
|
||
xml.write('<programme start="${stamp(start)}" '
|
||
'stop="${stamp(stop)}" channel="Chaine.$c.fr">'
|
||
'<title>Programme $p</title><desc>$desc</desc>'
|
||
'</programme>');
|
||
}
|
||
}
|
||
xml.write('</tv>');
|
||
final body = xml.toString();
|
||
|
||
final service = XmltvEpgService(
|
||
sourceUrls: const ['http://dump.example/epg.xml'],
|
||
client: MockClient((_) async => http.Response(body, 200)),
|
||
);
|
||
|
||
// Un « battement » toutes les 10 ms : le plus long écart observé est
|
||
// le temps pendant lequel la boucle d'événements est restée bloquée.
|
||
var last = DateTime.now();
|
||
var worstGap = Duration.zero;
|
||
final heartbeat = Timer.periodic(const Duration(milliseconds: 10), (_) {
|
||
final now = DateTime.now();
|
||
final gap = now.difference(last);
|
||
if (gap > worstGap) worstGap = gap;
|
||
last = now;
|
||
});
|
||
|
||
await service.ensureFresh();
|
||
heartbeat.cancel();
|
||
// Le dernier blocage n'est suivi d'aucun battement : le compter aussi.
|
||
final tail = DateTime.now().difference(last);
|
||
if (tail > worstGap) worstGap = tail;
|
||
|
||
expect(service.channelCount, greaterThanOrEqualTo(7000));
|
||
expect(
|
||
worstGap,
|
||
lessThan(const Duration(milliseconds: 250)),
|
||
reason: 'boucle d’événements gelée ${worstGap.inMilliseconds} ms',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('horizon', () {
|
||
test('écarte les programmes au-delà de l’horizon d’indexation', () {
|
||
// Un dump national couvre sept jours : tout garder ferait grossir
|
||
// l'index pour un guide qui n'affiche que le programme courant.
|
||
final service = XmltvEpgService(
|
||
sourceUrls: const [],
|
||
horizon: const Duration(hours: 48),
|
||
);
|
||
final far = DateTime.now().toUtc().add(const Duration(days: 5));
|
||
|
||
expect(
|
||
service.parseForTest(_fixture(far, far.add(const Duration(hours: 1)))),
|
||
isEmpty,
|
||
);
|
||
});
|
||
});
|
||
}
|