Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s

- Tests: the four known failures fixed (test-side causes), one duplicate
  file and four detail-pinning cases removed, every test that pins
  behaviour the restructure changes tagged with its step.
- Every toast, sound and activity-log line goes through Notifier.notify
  (AppEvent), mapped one-to-one to today's behaviour.
- Platform ports (shell, URLs, folders, link resolver, system
  notifications) with desktop implementations; window_manager, Process
  and file_picker now live only in platform/desktop/.
- ROADMAP: protocol 16 planned; app id dev.foxyz.starsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 19:24:11 +03:00
1 parent 7e98ebd1db
commit a1b270fd4e
63 files changed
+1231 -434

No files matched your search

@@ -1,3 +1,5 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
import 'dart:async';
import 'dart:io';
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
// part of the one computed status (and the source-gone half of P2).
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 4 (Library + Media identity): series
// grouping on the page follows L1/L2.
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
// becomes L1 local grouping; the scan and classify tests stay.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): hosted recents and the host
// card; the rest stays.
import 'dart:convert';
import 'dart:io' show Platform;
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
+2 -5
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert';
import 'package:flutter/gestures.dart';
@@ -364,11 +366,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
});
test('the floating prompt starts two minutes before the end', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
});
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): the "solo" and "hosting"
// tests (D9: no solo mode); the rest stays.
import 'dart:convert';
import 'package:flutter/material.dart';
-33
View File
@@ -1,33 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/widgets/media_input.dart';
void main() {
test('http url routes to linkUrl', () {
expect(parseMediaInput('http://example.com/v.mp4'),
(mediaPath: '', linkUrl: 'http://example.com/v.mp4'));
});
test('https url routes to linkUrl', () {
expect(parseMediaInput('https://example.com/v.mp4'),
(mediaPath: '', linkUrl: 'https://example.com/v.mp4'));
});
test('scheme match is case-insensitive and trimmed', () {
expect(parseMediaInput(' HTTPS://example.com/v.mp4 '),
(mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4'));
});
test('file path routes to mediaPath', () {
expect(parseMediaInput('/home/u/Videos/chiyo.mp4'),
(mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: ''));
});
test('empty input stays empty', () {
expect(parseMediaInput(' '), (mediaPath: '', linkUrl: ''));
});
test('non-http scheme stays a path', () {
expect(parseMediaInput('ftp://example.com/v.mp4'),
(mediaPath: 'ftp://example.com/v.mp4', linkUrl: ''));
});
}
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): invite addresses (P24) and
// the hosting invite; the settings cards stay.
import 'dart:io';
import 'package:flutter/material.dart';
+2 -10
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
// (one computed level); the title block and overflow checks stay.
import 'dart:typed_data';
import 'package:flutter/material.dart';
@@ -165,16 +167,6 @@ void main() {
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('a new media fades through', (tester) async {
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
await tester.pumpAndSettle();
expect(find.text('Frieren'), findsNothing);
expect(find.text('Dune'), findsOneWidget);
});
});
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
+2
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 5 (What's next): who performs an item and
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/queue/queue_logic.dart';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
+20 -5
View File
@@ -1,3 +1,6 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
// source drops uncleanly and resumes" (resume token, D6 source resume); the
// rest stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -65,6 +68,7 @@ Future<(HttpServer, List<String>)> _origin(String file) async {
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
@@ -102,7 +106,15 @@ void main() {
MediaKit.ensureInitialized();
all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
await server.start();
// Relic reports an error writing a response (a relay read cut short
// when its relay is dropped at a swap or at the stop in tearDown) to
// the zone the server started in, as it logs it outside tests;
// anything else fails the test.
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
});
tearDown(() async {
@@ -110,6 +122,7 @@ void main() {
await x.dispose();
}
await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
});
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
@@ -127,8 +140,9 @@ void main() {
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.sourceToken, bob.sync.token);
// The next swap retires bob's source and the buffer.
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
// The next swap retires bob's source and the buffer. Nothing answers
// at that link, so its probe refuses it unless put up anyway.
expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull);
await _poll(() => !bob.relaying.peek(), 'source stopped');
expect(server.hub.relay, isNull);
});
@@ -155,7 +169,8 @@ void main() {
await playTogether([alice, bob]);
expect(hits.length, greaterThan(before));
// A page yt-dlp cannot read is refused up front.
// A page is refused up front when this computer cannot read it (here
// yt-dlp is off, and the refusal says so).
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) {
r.response.headers.contentType = ContentType.html;
@@ -163,7 +178,7 @@ void main() {
r.response.close();
});
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
contains('Not a video'));
'yt-dlp is turned off');
await page.close(force: true);
} finally {
await origin.close(force: true);
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): "solo: the button says
// Play"; the rest stays.
import 'dart:convert';
import 'dart:io';
@@ -1,3 +1,5 @@
// restructure: rewrite in step 3 (Your server): one hub with listeners
// attached, nothing restarts, no solo mode.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -1,3 +1,6 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence):
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
// linkDisplayName stays.
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/idle.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
+4 -24
View File
@@ -1,3 +1,5 @@
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status
// pill; in step 3 (Your server): the solo strip and Invite friends.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -79,6 +81,8 @@ void main() {
autoUpdateCheck: false,
windowControls: false,
videoBuilder: (context, controls) => const FakeVideo(),
// A real probe (network, yt-dlp) never answers on the test clock.
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
),
));
await tester.pumpAndSettle();
@@ -192,18 +196,6 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('tabs: the underline moves to the selected one', (tester) async {
await pumpShell(tester, const Size(1280, 800));
double underline(String tab) => tester
.getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer)))
.width;
expect(underline('library'), 22);
expect(underline('settings'), 0);
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(underline('library'), 0);
expect(underline('settings'), 22);
});
});
group('popovers', () {
@@ -418,16 +410,4 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Shaders off'), findsNothing);
});
testWidgets('status avatar fills its box at any size', (tester) async {
await tester.pumpWidget(const MaterialApp(
home: Center(
child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88),
),
));
final circle = tester.getSize(find.byType(CircleAvatar));
// Ring and gap (2 x 4 px each side at this size) inside the 88 px box.
expect(circle.width, 88 - 4 * 4);
expect(find.text('Z'), findsOneWidget);
});
}
@@ -1,3 +1,5 @@
// restructure: rewrite in step 6 (Media pipeline + player host): the
// fresh player per video.
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';