Files
sync_replay_project/apps/desktop/test/show_matcher_test.dart
T
foxyzandClaude Opus 5.5 bf9fc193b3
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s
Restructure step 4: local library and media identity
- L1 (core, offline): release names with kinds (episode, movie, extra:
  OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from
  parentheticals, ranges), one show across folders, gaps and next with
  skipped numbers, junk ignored, stable keys.
- L2 (app, device-owned cache): AniList match with synonyms and
  sequel walking, episode mapping across cours (AnimeFlow runs when
  signed in), art and episode titles, "Wrong show?" pins per show or
  folder, episode offsets; lazy, spaced requests, works offline.
- Files tab as a library: poster cards, show pages with banner, gap
  prompt, extras, movies, Wrong show? and Episode numbers.
- media.ref produced for local files, Jellyfin (provider ids) and links,
  carried in swaps and Up next; receivers name media from it.
- Hosting relays each Jellyfin item through its own server's login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:34:39 +03:00

302 lines
13 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'package:starsync/src/schedule/animeflow.dart' show AniListRun;
import 'package:sync_relay_core/core.dart';
// L2: local shows (L1 over the Binux names) matched against a fake
// AniList: synonyms and season hints, numbering along split entries,
// pins and offsets that win, the cache answering offline, spaced requests.
const root = '/lib/Binux';
const b2 = '$root/[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
const b3 = '$root/[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
const sao = '$root/[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
const reS1 = 'Re:Zero kara Hajimeru Isekai Seikatsu';
const reEn = 'Re:ZERO -Starting Life in Another World-';
final entries = <AniListEntry>[
const AniListEntry(id: 269, romaji: 'BLEACH', english: 'Bleach', format: 'TV', episodes: 366, year: 2004),
const AniListEntry(
id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13),
const AniListEntry(
id: 21355, romaji: reS1, english: reEn, synonyms: ['Re:Zero', 'ReZero'], format: 'TV', episodes: 25, sequel: 108632),
const AniListEntry(
id: 108632, romaji: '$reS1 2nd Season', english: '$reEn Season 2', format: 'TV', episodes: 13, sequel: 119661),
const AniListEntry(
id: 119661, romaji: '$reS1 2nd Season Part 2', english: '$reEn Season 2 Part 2', format: 'TV', episodes: 12, sequel: 163134),
const AniListEntry(
id: 163134, romaji: '$reS1 3rd Season', english: '$reEn Season 3', format: 'TV', episodes: 16, sequel: 189046),
const AniListEntry(id: 189046, romaji: '$reS1 4th Season', english: '$reEn Season 4', format: 'TV'),
const AniListEntry(id: 11757, romaji: 'Sword Art Online', format: 'TV', episodes: 25, sequel: 20594),
const AniListEntry(id: 20594, romaji: 'Sword Art Online II', format: 'TV', episodes: 24),
const AniListEntry(
id: 100182,
romaji: 'Sword Art Online: Alicization',
format: 'TV',
episodes: 24,
sequel: 108759,
episodeTitles: {1: 'Underworld'}),
const AniListEntry(
id: 108759, romaji: 'Sword Art Online: Alicization - War of Underworld', format: 'TV', episodes: 12, sequel: 114308),
const AniListEntry(
id: 114308, romaji: 'Sword Art Online: Alicization - War of Underworld 2nd Season', format: 'TV', episodes: 11),
const AniListEntry(
id: 177937,
romaji: 'Kimetsu no Yaiba: Mugen Jou-hen',
english: 'Demon Slayer: Kimetsu no Yaiba Infinity Castle',
format: 'MOVIE',
episodes: 1),
const AniListEntry(id: 101922, romaji: 'Kimetsu no Yaiba', english: 'Demon Slayer: Kimetsu no Yaiba', format: 'TV', episodes: 26),
];
/// Answers searches by shared words (AniList's own order roughly), and
/// ids from [entries]; counts the requests.
class FakeAniList implements AniListLookup {
final searches = <String>[];
final lookups = <int>[];
bool offline = false;
static Set<String> _words(String s) =>
normalizeSeries(s).split(' ').where((w) => w.length > 1 && int.tryParse(w) == null && w != 'season').toSet();
@override
Future<List<AniListEntry>> searchAnime(String query) async {
if (offline) throw const AniListException('AniList unreachable');
searches.add(query);
final q = _words(query);
final scored = [
for (final e in entries)
(e, e.names.map((n) => _words(n).intersection(q).length).reduce((a, b) => a > b ? a : b)),
].where((p) => p.$2 > 0).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
return [for (final p in scored.take(8)) p.$1];
}
@override
Future<AniListEntry?> anime(int id) async {
if (offline) throw const AniListException('AniList unreachable');
lookups.add(id);
return entries.where((e) => e.id == id).firstOrNull;
}
}
class FakeFlow implements FlowLookup {
final Map<int, FlowShow> shows;
FakeFlow(this.shows);
@override
Future<FlowShow?> forAniList(int anilistId) async => shows[anilistId];
}
String three(int n) => n.toString().padLeft(3, '0');
LocalLibrary binux({List<String> extra = const []}) => groupLocal([
for (final p in [
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) '$b2/[Judas] Bleach - ${three(n)}.mkv',
for (var n = 112; n <= 139; n++) '$b3/[Judas] Bleach - ${three(n)}.mkv',
for (var n = 1; n <= 24; n++) '$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv',
'$sao/[Judas] Sword Art Online S3 - Opening 1.mkv',
'$root/[Judas] Re.Zero - S04E17.mkv',
'$root/[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv',
...extra,
])
LocalFile(p, 1 << 30, DateTime(2026)),
], roots: [root]);
void main() {
late FakeAniList aniList;
late MemoryLibraryStore store;
late List<Duration> waits;
ShowMatcher matcher({FlowLookup? flow, AniListLookup? api}) => ShowMatcher(
store: store,
aniList: api ?? aniList,
flow: flow,
delay: (d) async => waits.add(d),
);
Future<void> settle(ShowMatcher m) async {
for (var i = 0; i < 200 && !m.idle; i++) {
await Future<void>.delayed(Duration.zero);
}
await Future<void>.delayed(Duration.zero);
expect(m.idle, isTrue);
}
Future<ShowMatcher> matched(LocalLibrary lib, {FlowLookup? flow}) async {
final m = matcher(flow: flow)..remember(lib);
for (final s in lib.shows) {
m.want(s);
}
for (final mv in lib.movies) {
m.wantMovie(mv);
}
await settle(m);
return m;
}
LocalEpisode ep(LocalLibrary lib, String show, int n, {int? season}) =>
lib.show(show)!.episodes.firstWhere((e) => e.episode == n && (season == null || e.season == season));
setUp(() {
aniList = FakeAniList();
store = MemoryLibraryStore();
waits = [];
});
test('each show and movie finds its AniList entry, names and numbers follow', () async {
final lib = binux();
final m = await matched(lib);
// Bleach: one 366-episode entry, absolute numbers as is.
final bleach = lib.show('bleach')!;
expect(m.titleOf(bleach), 'Bleach');
expect(m.refFor(ep(lib, 'bleach', 108)),
const MediaRef(kind: 'episode', title: 'Bleach', anilistId: 269, anilistEp: 108, absolute: 108));
// Re.Zero S04E17: the season hint finds the 4th season among the
// synonyms and spellings ("Re.Zero" is "Re:ZERO -Starting Life…").
final re = lib.show('re zero')!;
expect(m.showMatch(re)!.entry.id, 189046);
expect(m.titleOf(re), '$reEn Season 4');
expect(m.refFor(re.episodes.single),
MediaRef(kind: 'episode', title: '$reEn Season 4', anilistId: 189046, anilistEp: 17, season: 4, ep: 17));
// SAO: the folder's "(Season 3 - Alicization)" names the entry; its
// extras belong to it too; AniList's episode title shows.
final s = lib.show('sword art online')!;
expect(m.showMatch(s)!.entry.id, 100182);
expect(m.target(ep(lib, 'sword art online', 13)), const EpisodeTarget(100182, 13));
expect(m.episodeTitle(ep(lib, 'sword art online', 1)), 'Underworld');
expect(m.refFor(s.extras.single), const MediaRef(kind: 'extra', title: 'Sword Art Online: Alicization', anilistId: 100182));
// The movie is the movie entry, not the TV show.
final movie = lib.movies.single;
expect(m.seasonMatch(movie)!.entry.id, 177937);
expect(m.refFor(movie).kind, 'movie');
});
test('past an entry\'s episodes the numbering goes on along its sequels (split cours)', () async {
final lib = binux(extra: [
'$sao/[Judas] Sword Art Online S3 - 25.mkv',
'$sao/[Judas] Sword Art Online S3 - 38.mkv',
]);
final m = await matched(lib);
// 25 is War of Underworld 1 (before that entry is even fetched: its
// id comes with Alicization), 38 its second part's 2.
expect(m.target(ep(lib, 'sword art online', 25)), const EpisodeTarget(108759, 1));
m.target(ep(lib, 'sword art online', 38));
await settle(m);
expect(m.target(ep(lib, 'sword art online', 38)), const EpisodeTarget(114308, 2));
expect(aniList.lookups, containsAll([108759]));
});
test('AnimeFlow\'s anilist_map wins for numbered seasons and gives the TVDB id', () async {
final lib = binux();
final flow = FakeFlow({
100182: const FlowShow([
AniListRun(season: 3, anilistId: 100182, firstEpisode: 1, offset: 0),
AniListRun(season: 3, anilistId: 108759, firstEpisode: 25, offset: 24),
], tvdbId: 259640),
});
final m = await matched(lib, flow: flow);
await settle(m);
final r = m.refFor(ep(lib, 'sword art online', 13));
expect([r.anilistId, r.anilistEp, r.tvdbId, r.season, r.ep], [100182, 13, 259640, 3, 13]);
});
test('pins win over the match, per show and per folder, and survive a rescan', () async {
final lib = binux();
final m = await matched(lib);
final tybw = entries.firstWhere((e) => e.id == 41467);
final reZero = entries.firstWhere((e) => e.id == 21355);
m.pinShow('bleach', tybw);
expect(m.showMatch(lib.show('bleach')!)!.pinned, PinScope.show);
expect(m.target(ep(lib, 'bleach', 60))!.anilistId, 41467);
// A folder pin is more specific than the show's.
m.pinFolder(b3, reZero);
expect(m.target(ep(lib, 'bleach', 120))!.anilistId, isNot(41467));
expect(m.seasonMatch(ep(lib, 'bleach', 120))!.pinned, PinScope.folder);
expect(m.seasonMatch(ep(lib, 'bleach', 60))!.entry.id, 41467);
// A rescan (a new library, a new run) over the same store: the pins
// hold, with AniList out of reach.
final again = binux();
final m2 = matcher(api: FakeAniList()..offline = true)..remember(again);
for (final s in again.shows) {
m2.want(s);
}
await settle(m2);
expect(m2.seasonMatch(ep(again, 'bleach', 60))!.entry.id, 41467);
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 21355);
m2.unpinFolder(b3);
m2.unpinShow('bleach');
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 269);
});
test('an episode offset: "these files are episodes N.."', () async {
final lib = binux();
final m = await matched(lib);
// The Alicization files are really War of Underworld 1-24 here.
m.setOffset('sword art online', 3, 24);
final r = m.refFor(ep(lib, 'sword art online', 1));
expect([r.ep, r.anilistId, r.anilistEp], [25, 108759, 1]);
m.setOffset('sword art online', 3, 0);
expect(m.refFor(ep(lib, 'sword art online', 1)).anilistEp, 1);
});
test('cached: one search per season, none again (offline too), requests spaced out', () async {
final lib = binux();
final m = await matched(lib);
final asked = aniList.searches.length;
expect(asked, greaterThanOrEqualTo(4));
expect(waits.where((w) => w > Duration.zero), isNotEmpty, reason: 'spaced, not a burst');
for (final s in lib.shows) {
m.want(s);
}
await settle(m);
expect(aniList.searches.length, asked);
final offline = FakeAniList()..offline = true;
final m2 = matcher(api: offline)..remember(lib);
for (final s in lib.shows) {
m2.want(s);
}
await settle(m2);
expect(offline.searches, isEmpty);
expect(m2.refFor(ep(lib, 'bleach', 108)), m.refFor(ep(lib, 'bleach', 108)));
expect(m2.titleOf(lib.show('re zero')!), '$reEn Season 4');
});
test('before a match (or offline with nothing cached) a ref still says what L1 read', () async {
final lib = binux();
final m = matcher(api: FakeAniList()..offline = true)..remember(lib);
m.want(lib.show('bleach')!);
await settle(m);
expect(m.refFor(ep(lib, 'bleach', 56)), const MediaRef(kind: 'episode', title: 'Bleach', absolute: 56));
// A file never scanned: its name and folder alone.
expect(m.refForPath('$sao/[Judas] Sword Art Online S3 - 07.mkv'),
const MediaRef(kind: 'episode', title: 'Sword Art Online', season: 3, ep: 7));
});
test('the ref fits the wire: round-trips and stays under 2 KiB', () async {
final long = 'A Very Long Light Novel Title ' * 12;
final lib = groupLocal([LocalFile('$root/$long - 05.mkv', 1, DateTime(2026))], roots: [root]);
final m = matcher(api: FakeAniList()..offline = true);
final ref = m.refFor(lib.shows.single.episodes.single);
final media = HelloMedia(name: 'x.mkv', size: 1, duration: 0, ref: ref);
final json = jsonEncode(media.toJson());
final back = HelloMedia.fromJson(jsonDecode(json) as Map<String, Object?>);
expect(back.ref!.kind, 'episode');
expect(back.ref!.absolute, 5);
expect(utf8.encode(jsonEncode(ref.toJson())).length, lessThan(RelayConstants.maxMediaRefBytes));
expect(back.ref!.title.length, lessThanOrEqualTo(200));
});
}