Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m56s

- notice_table_test: one row per AppEvent variant through noticeFor.
- FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay,
  hold, cut after N bytes, reset; demo tests against today's server.
- Liveness (stale/drop/ping) on the engine clock, so grace can be tested
  with a fake clock; Connection takes an injectable clock.
- AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two
  instances run on one machine.
- DEVELOPMENT: scenario list per restructure step; test tags follow the
  new step order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 20:05:52 +03:00
1 parent eac45746ce
commit f4ca02c3cf
23 files changed
+959 -48

No files matched your search

+7 -3
View File
@@ -7,6 +7,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/platform/app_dirs.dart';
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop_entry.dart';
@@ -41,14 +42,17 @@ Future<void> main(List<String> args) async {
),
);
WidgetsFlutterBinding.ensureInitialized();
await startAppLogging();
await Hive.initFlutter();
final dirs = await AppDirs.resolve();
await startAppLogging(dirs);
// An absolute subDir replaces the documents folder (package:path join).
await Hive.initFlutter(dirs.hive);
await AppSettings.instance.ensureLoaded();
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", a hosted server shuts down and no player outlives the
// engine.
final ports = SessionState.instance.ports;
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
try {
await SessionState.instance.loadForm();
} catch (e, st) {
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` ([resolve])
/// and handed on from there.
///
/// Without a profile it is exactly today's layout: Hive's boxes in Hive's
/// default folder (the documents folder) and the logs in
/// `<app support>/logs`. With `STARSYNC_PROFILE=<name>` (letters, digits,
/// `-`, `_`; at most 32) the per-install data moves to
/// `<app support>/profiles/<name>/`: the Hive boxes (settings and so the
/// profile, connect form, window bounds, folders, Jellyfin sign-ins; the
/// client id from restructure step 1 on) under `hive/`, the logs under
/// `logs/`. Downloads and caches stay shared: yt-dlp and its cookies, the
/// extracted shaders, thumbnails, the AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; logs without a profile).
final String support;
/// Hive's folder; null: Hive's default (`Hive.initFlutter()`).
final String? hive;
/// The app's log folder.
final String logs;
const AppDirs({required this.profile, required this.support, required this.hive, required this.logs});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv({required String support, required Map<String, String> env}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
if (name == null) return AppDirs(profile: null, support: support, hive: null, logs: '$support${sep}logs');
final root = '$support${sep}profiles$sep$name';
return AppDirs(profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs');
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
/// Resolves the layout from the platform and the process environment.
/// Needs Flutter's binding (path_provider).
static Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final dirs = fromEnv(support: support, env: Platform.environment);
final hive = dirs.hive;
if (hive != null) await Directory(hive).create(recursive: true);
return dirs;
}
}
+9 -8
View File
@@ -2,10 +2,10 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'app_dirs.dart';
import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
@@ -17,13 +17,11 @@ final _appLog = createLogger('app');
/// The logs folder, once [startAppLogging] ran.
String? appLogDir;
/// Opens the log files and routes `debugPrint` and uncaught errors into
/// the log. Needs Flutter's binding (path_provider).
Future<void> startAppLogging() async {
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
/// uncaught errors into the log.
Future<void> startAppLogging(AppDirs dirs) async {
try {
final base = await getApplicationSupportDirectory();
final dir = '${base.path}${Platform.pathSeparator}logs';
if (startLogFiles(dir) != null) appLogDir = dir;
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
} catch (e) {
_appLog.warning('no log files: $e');
}
@@ -39,7 +37,10 @@ Future<void> startAppLogging() async {
return false;
};
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
final bad = AppDirs.invalidProfile(Platform.environment);
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
}
/// The text "Copy diagnostics" gives: versions, then the last [since] of
+12 -5
View File
@@ -196,23 +196,30 @@ class Connection {
final Duration reconnectDelay;
/// No state broadcast for this long means the connection is lost.
final Duration silenceLimit;
/// The clock [silenceLimit] is measured on (UTC; a test passes a fake
/// one). The heartbeat that checks it stays a real 1 s timer.
final DateTime Function() now;
Connection(
{void Function(AppEvent event)? notify,
SyncClient? client,
this.hello = defaultHello,
this.reconnectDelay = const Duration(milliseconds: 1500),
this.silenceLimit = const Duration(seconds: 5)})
this.silenceLimit = const Duration(seconds: 5),
DateTime Function()? now})
: _notify = notify ?? ((_) {}),
now = now ?? _utcNow,
client = client ?? SyncClient() {
_subs = [
this.client.status.listen(_onStatus),
this.client.states.listen((_) => _lastStateAt = DateTime.now().toUtc()),
this.client.states.listen((_) => _lastStateAt = this.now()),
];
}
late final List<StreamSubscription> _subs;
static DateTime _utcNow() => DateTime.now().toUtc();
final _state = signal<ConnState>(const ConnClosed());
/// Every change is notified, a repeat too.
ReadonlySignal<ConnState> get state => _state;
@@ -368,7 +375,7 @@ class Connection {
_notify(const ConnectionBack());
}
_resumeToken = client.token;
_lastStateAt = DateTime.now().toUtc();
_lastStateAt = now();
_set(const ConnOnline());
_startHeartbeat();
}
@@ -400,13 +407,13 @@ class Connection {
/// Every second while connected: a clock sample, the diagnostics, and
/// the silence check.
void _beat() {
final now = DateTime.now().toUtc();
final t = now();
if (client.token.isNotEmpty) client.sendPing();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..tick();
final last = _lastStateAt;
if (!_closed && last != null && now.difference(last) >= silenceLimit) {
if (!_closed && last != null && t.difference(last) >= silenceLimit) {
_silence.add(null);
if (!_state.peek().retrying) _dropped();
}
+36
View File
@@ -0,0 +1,36 @@
// restructure: rewrite in step R (Rename): Hive moves into the support
// folder for the normal app too.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/app_dirs.dart';
void main() {
final sep = Platform.pathSeparator;
const support = '/s';
test('no profile: today\'s layout', () {
final d = AppDirs.fromEnv(support: support, env: const {});
expect(d.profile, isNull);
expect(d.hive, isNull, reason: "Hive's default folder");
expect(d.logs, '$support${sep}logs');
});
test('a profile gets its own Hive and logs folders', () {
final d = AppDirs.fromEnv(support: support, env: const {profileEnv: 'b'});
expect(d.profile, 'b');
expect(d.hive, '$support${sep}profiles${sep}b${sep}hive');
expect(d.logs, '$support${sep}profiles${sep}b${sep}logs');
expect(d.support, support, reason: 'yt-dlp and shaders stay shared');
});
test('an unusable name is ignored and reported', () {
for (final bad in ['../x', 'a b', 'x' * 33, 'é']) {
final env = {profileEnv: bad};
expect(AppDirs.fromEnv(support: support, env: env).profile, isNull, reason: bad);
expect(AppDirs.invalidProfile(env), bad);
}
expect(AppDirs.invalidProfile(const {profileEnv: ''}), isNull, reason: 'empty is just unset');
expect(AppDirs.invalidProfile(const {profileEnv: 'ok_1-2'}), isNull);
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async';
import 'dart:convert';
+1 -1
View File
@@ -1,4 +1,4 @@
// restructure: rewrite in step 5 (What's next + Watch tracking): the
// restructure: rewrite in step 6 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert';
+141
View File
@@ -0,0 +1,141 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
// feed rows follow the new feed actions one-to-one (away/in/gone/return);
// step 2 (Status + Notifier): every row, when noticeFor takes a context.
//
// The Notifier policy as a table: one row per AppEvent subtype, what
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
// line). A policy change edits a row here on purpose.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage;
import 'package:watch_together/src/session/notifier.dart';
import 'package:watch_together/src/sfx/sfx.dart';
final _at = DateTime.utc(2026, 1, 1);
FeedMessage _feed(String action, {String actor = 'Ann', String? target, String reason = ''}) =>
FeedMessage(actor: actor, action: action, target: target, reason: reason, at: _at);
/// One row: the event, then what it must do.
class _Row {
final String name;
final AppEvent event;
final Type type;
final String? toast;
final String? actor;
final SfxKind? sound;
final int readyIndex;
final bool attention;
/// Whether the event's own feed line goes into the activity log.
final bool logged;
const _Row(this.name, this.event, this.type,
{this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false});
}
/// The feed as the coordinator turns it into events: this member is 'Me',
/// two members ready in the latest state.
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfName: 'Me', readyCount: 2);
final _rows = <_Row>[
// ---- the feed (FeedEvent.of, then noticeFor); every line is logged
_Row('join', _of(_feed('join')), MemberJoined,
toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
_Row('leave', _of(_feed('leave')), MemberLeft,
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('stale', _of(_feed('stale')), MemberStale, logged: true),
_Row('out, someone else sat out', _of(_feed('out', target: 'Bob')), MemberSatOut, logged: true),
_Row('out, sat yourself out', _of(_feed('out', actor: 'Me', target: 'Me')), MemberSatOut, logged: true),
_Row('out, someone sat you out', _of(_feed('out', target: 'Me')), MemberSatOut,
toast: 'Ann sat you out', actor: 'Ann', sound: SfxKind.kick, logged: true),
_Row('back', _of(_feed('back')), MemberBack, logged: true),
_Row('ready', _of(_feed('ready')), ReadyChanged, sound: SfxKind.ready, readyIndex: 2, logged: true),
_Row('unready', _of(_feed('unready')), ReadyChanged, sound: SfxKind.unready, logged: true),
_Row('force-ready', _of(_feed('force-ready')), ForcedReady, sound: SfxKind.forceReady, logged: true),
_Row('force-unready', _of(_feed('force-unready')), ForcedReady, logged: true),
_Row('force-play', _of(_feed('force-play')), ForcedTransport, logged: true),
_Row('force-hold', _of(_feed('force-hold')), ForcedTransport, logged: true),
_Row('swap', _of(_feed('swap')), MediaSwapped,
toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true),
_Row('seek', _of(_feed('seek')), Seeked, logged: true),
_Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true),
_Row('resume', _of(_feed('resume', actor: 'server')), Resumed, logged: true),
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
toast: 'Me: hi', actor: 'Me', logged: true),
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
_Row('avatar', _of(_feed('avatar')), AvatarChanged, logged: true),
_Row('queue-add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, logged: true),
_Row('queue-remove', _of(_feed('queue-remove', target: 'Ep 6')), QueueRemoved, logged: true),
_Row('queue-skip with a reason', _of(_feed('queue-skip', actor: 'server', target: 'Ep 6', reason: 'gone')),
QueueSkipped, toast: 'Skipped Ep 6: gone', logged: true),
_Row('queue-skip, no title or reason', _of(_feed('queue-skip', actor: 'server')), QueueSkipped,
toast: 'Skipped an item', logged: true),
_Row('fetch-fail', _of(_feed('fetch-fail', actor: 'server', reason: '404')), FetchFailed, logged: true),
_Row('an unknown action', _of(_feed('from-the-future')), OtherFeed, logged: true),
// ---- the session
_Row('nudge', const Nudged('Ann'), Nudged,
toast: 'Ann nudged you', actor: 'Ann', sound: SfxKind.nudge, attention: true),
_Row('nudge from someone unknown', const Nudged(''), Nudged,
toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true),
_Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay,
toast: "Ann can't play this: no codec", actor: 'Ann'),
_Row('slow source', const SlowSource('Passing on slower than it plays'), SlowSource,
toast: 'Passing on slower than it plays'),
_Row('relay started', const RelayStarted(), RelayStarted,
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
_Row('relay failed', const RelayFailed('closed'), RelayFailed, toast: 'Cannot pass the video on: closed'),
_Row('idle, unready', const IdleWarning(sitsOut: false), IdleWarning,
toast: 'idle — auto-unready in 2 min unless you press Space', sound: SfxKind.warn),
_Row('idle, sits out', const IdleWarning(sitsOut: true), IdleWarning,
toast: 'idle — sitting out in 2 min unless you press Space', sound: SfxKind.warn),
_Row('connection lost', const ConnectionLost(), ConnectionLost, sound: SfxKind.lost),
_Row('connection back', const ConnectionBack(), ConnectionBack, sound: SfxKind.back),
_Row('jellyfin, sign-in expired', const JellyfinProgressFailed(signInExpired: true), JellyfinProgressFailed,
toast: 'Jellyfin progress not saved: sign-in expired'),
_Row('jellyfin, unreachable', const JellyfinProgressFailed(signInExpired: false), JellyfinProgressFailed,
toast: 'Jellyfin progress not saved: server unreachable'),
// ---- the app
_Row('shader message', const ShaderMessage('Shaders off'), ShaderMessage, toast: 'Shaders off'),
_Row('added to up next', const AddedToUpNext('Ep 6'), AddedToUpNext, toast: 'Added Ep 6 to Up next'),
_Row('action failed', const ActionFailed('No such file'), ActionFailed, toast: 'No such file'),
_Row('schedule result', const ScheduleResult('Loaded'), ScheduleResult, toast: 'Loaded'),
_Row('problem marked in session', const ProblemMarked(inSession: true), ProblemMarked,
toast: 'Marked in the session log'),
_Row('problem marked locally', const ProblemMarked(inSession: false), ProblemMarked,
toast: "Marked in this app's log"),
_Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'),
_Row('copy failed', const CopyFailed(), CopyFailed, toast: "Couldn't copy"),
_Row('update available', const UpdateAvailable('1.3.0'), UpdateAvailable),
];
void main() {
group('noticeFor', () {
for (final r in _rows) {
test(r.name, () {
expect(r.event.runtimeType, r.type, reason: 'event type');
final n = noticeFor(r.event);
expect(n.toast, r.toast, reason: 'toast');
expect(n.actor, r.actor, reason: 'actor');
expect(n.sound?.kind, r.sound, reason: 'sound');
if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch');
expect(n.attention, r.attention, reason: 'attention');
final e = r.event;
expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log');
});
}
});
test('every concrete AppEvent subtype has a row', () {
// Read from the source, so a new event fails here until it gets a row.
final src = File('lib/src/session/app_events.dart').readAsStringSync();
final declared = RegExp(r'^class (\w+) extends (?:FeedEvent|AppEvent)', multiLine: true)
.allMatches(src)
.map((m) => m.group(1)!)
.toSet();
expect(declared, isNotEmpty);
expect(_rows.map((r) => r.type.toString()).toSet(), declared);
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
// restructure: rewrite in step 5 (What's next): who performs an item and
// restructure: rewrite in step 6 (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';
+1 -1
View File
@@ -1,4 +1,4 @@
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
import 'dart:async';
import 'dart:convert';