import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
/// Fixture shaped exactly like the community dumps the fallback consumes
/// (channel ids with dots, `+0200` offsets, base-less titles).
String _fixture(DateTime now) {
String stamp(DateTime value) {
String two(int n) => n.toString().padLeft(2, '0');
final utc = value.toUtc();
return '${utc.year}${two(utc.month)}${two(utc.day)}'
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
}
final airingStart = now.subtract(const Duration(minutes: 20));
final airingEnd = now.add(const Duration(minutes: 40));
final nextEnd = airingEnd.add(const Duration(minutes: 50));
final farFuture = now.add(const Duration(days: 4));
return '''
TF1
France 3 - Nord Pas-de-Calais
Ciné+ Premier
Journal de 20h
Le rendez-vous d'information.
Film du mardi
Ici 19/20
Hors fenêtre
''';
}
Stream> _bytes(String xml) => Stream.value(utf8.encode(xml));
void main() {
group('parseXmltvTime', () {
test('applies the trailing UTC offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
expect(parsed, isNotNull);
expect(
parsed!.toUtc(),
DateTime.utc(2026, 8, 11, 16, 30),
reason: '18:30 at +0200 is 16:30 UTC',
);
});
test('handles a negative offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
});
test('treats a missing offset as UTC', () {
final parsed = XmltvService.parseXmltvTime('20260811183000');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
});
test('rejects malformed input', () {
expect(XmltvService.parseXmltvTime(null), isNull);
expect(XmltvService.parseXmltvTime(''), isNull);
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
});
});
group('normalizeName', () {
test('collapses quality suffixes', () {
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
expect(XmltvService.normalizeName('TF1'), 'tf1');
});
test('strips country prefixes in both forms', () {
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
});
test('keeps a dash-separated name intact', () {
// "Ciné - Premier" must not lose its first word to prefix stripping.
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
});
test('folds accents and keeps the plus sign', () {
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
});
test('is empty for a blank label', () {
expect(XmltvService.normalizeName(' '), '');
});
});
group('indexFromBytes + lookup', () {
late XmltvService service;
late DateTime now;
setUp(() async {
service = XmltvService(Directory.systemTemp.path);
now = DateTime.now();
final indexed = await service.indexFromBytes(
_bytes(_fixture(now)),
now: now,
);
expect(indexed, isTrue);
});
test('matches on epg_channel_id', () {
final epg = service.shortEpgFor(
streamId: '123',
epgChannelId: 'TF1.fr',
channelName: 'anything',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
expect(epg.id, '123');
expect(epg.description, "Le rendez-vous d'information.");
});
test('falls back to the channel name when the id is unknown', () {
final epg = service.shortEpgFor(
streamId: '456',
epgChannelId: '',
channelName: 'FR: TF1 HD',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
});
test('matches a name reconstructed from a dotted channel id', () {
final epg = service.shortEpgFor(
streamId: '789',
epgChannelId: 'not-in-guide',
channelName: 'France 3 - Nord Pas-de-Calais',
);
expect(epg, isNotNull);
expect(epg!.title, 'Ici 19/20');
});
test('returns the currently airing programme, not the next one', () {
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: now,
);
expect(programme!.title, 'Journal de 20h');
expect(programme.covers(now), isTrue);
});
test('falls forward to the next entry when nothing is airing', () {
// 10 minutes into the gap-free schedule's second slot.
final during = now.add(const Duration(minutes: 50));
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: during,
);
expect(programme!.title, 'Film du mardi');
});
test('drops programmes outside the retention window', () {
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
// holds no programme at all.
final epg = service.shortEpgFor(
streamId: '999',
epgChannelId: 'Cine.Premier.fr',
);
expect(epg, isNull);
});
test('returns null for a channel absent from the guide', () {
final epg = service.shortEpgFor(
streamId: '000',
epgChannelId: 'unknown.channel',
channelName: 'Chaîne Inconnue',
);
expect(epg, isNull);
});
test('parses a gzipped payload transparently', () async {
final gzipped = XmltvService(Directory.systemTemp.path);
final compressed = gzip.encode(utf8.encode(_fixture(now)));
final indexed = await gzipped.indexFromBytes(
Stream.value(compressed),
now: now,
);
expect(indexed, isTrue);
expect(
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
'Journal de 20h',
);
});
test('reports failure on a payload with no programme', () async {
final empty = XmltvService(Directory.systemTemp.path);
final indexed = await empty.indexFromBytes(
_bytes(''),
);
expect(indexed, isFalse);
expect(empty.isLoaded, isFalse);
});
});
}