обновленная синхронизация клиента

This commit is contained in:
2026-10-04 21:15:18 +04:00
parent d3d19bfaeb
commit 18c8e07744
8 changed files with 749 additions and 249 deletions
@@ -1,8 +1,9 @@
import 'dart:async';
// import 'dart:collection';
import 'dart:collection';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
// import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
@@ -10,140 +11,265 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
void main() {
group('ClientSyncUseCase', () {
// test(
// 'completes without downloads when local snapshot matches manifest',
// () async {
// final manifest = _manifestFromFiles([
// const ClientManifestFile(
// path: 'Wow.exe',
// size: 5,
// sha256: 'wow-hash',
// ),
// const ClientManifestFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
// const LocalClientFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
test(
'completes without downloads when local snapshot matches manifest',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'MoonWell.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(
path: 'MoonWell.exe',
size: 5,
sha256: 'wow-hash',
),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
// final api = _FakeLauncherApiClient(manifest);
// final installationService = _FakeGameInstallationService([
// matchingSnapshot,
// ]);
// final logger = _FakeLauncherLogService();
// final useCase = ClientSyncUseCase(
// launcherApiClient: api,
// installationService: installationService,
// launcherLogService: logger,
// );
final api = _FakeLauncherApiClient(manifest);
final installationService = _FakeGameInstallationService([
matchingSnapshot,
]);
final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
launcherLogService: logger,
);
// final statuses = await useCase
// .call(
// ClientSyncUseCaseInput(
// request: ClientSyncRequest(
// installationDir: 'C:/MoonWell',
// accessToken: 'token',
// manifest: manifest,
// ),
// ),
// )
// .toList();
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
// expect(statuses.last.stage, ClientSyncStage.completed);
// expect(statuses.last.message, 'Клиент обновлён.');
// expect(api.downloadedPaths, isEmpty);
// expect(installationService.deletedRelativeFiles, isEmpty);
// expect(logger.errorMessages, isEmpty);
// },
// );
expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.message, 'Клиент обновлён.');
expect(api.downloadedPaths, isEmpty);
expect(installationService.deletedRelativeFiles, isEmpty);
expect(logger.errorMessages, isEmpty);
},
);
// test(
// 'removes stale files and downloads changed files before final verify',
// () async {
// final manifest = _manifestFromFiles([
// const ClientManifestFile(
// path: 'Wow.exe',
// size: 5,
// sha256: 'wow-hash',
// ),
// const ClientManifestFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
// ]);
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
// const LocalClientFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
test(
'removes stale files and downloads changed files before final verify',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'MoonWell.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final initialSnapshot = ClientInstallationSnapshot.fromFiles(
[
const LocalClientFile(
path: 'MoonWell.exe',
size: 5,
sha256: 'old-wow',
),
],
stalePaths: ['Data/legacy.MPQ', 'WoW.exe', 'old.dll'],
);
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(
path: 'MoonWell.exe',
size: 5,
sha256: 'wow-hash',
),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
// final api = _FakeLauncherApiClient(manifest);
// final installationService =
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
// 'wow-hash'
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
// 10
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
// 'data-hash';
// final logger = _FakeLauncherLogService();
// final useCase = ClientSyncUseCase(
// launcherApiClient: api,
// installationService: installationService,
// launcherLogService: logger,
// );
final api = _FakeLauncherApiClient(manifest);
final installationService =
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
..fileSizesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] = 5
..computedHashesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] =
'wow-hash'
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
10
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
'data-hash';
final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
launcherLogService: logger,
);
// final statuses = await useCase
// .call(
// ClientSyncUseCaseInput(
// request: ClientSyncRequest(
// installationDir: 'C:/MoonWell',
// accessToken: 'token',
// manifest: manifest,
// ),
// ),
// )
// .toList();
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
// expect(
// statuses.map((status) => status.stage),
// containsAll([
// ClientSyncStage.removingStaleFiles,
// ClientSyncStage.downloadingFiles,
// ClientSyncStage.verifyingInstallation,
// ClientSyncStage.completed,
// ]),
// );
// expect(installationService.deletedRelativeFiles, ['legacy.txt']);
// expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
// expect(statuses.last.message, 'Клиент готов к игре.');
// expect(logger.errorMessages, isEmpty);
// },
// );
expect(
statuses.map((status) => status.stage),
containsAll([
ClientSyncStage.removingStaleFiles,
ClientSyncStage.downloadingFiles,
ClientSyncStage.verifyingInstallation,
ClientSyncStage.completed,
]),
);
expect(installationService.deletedRelativeFiles, [
'Data/legacy.MPQ',
'WoW.exe',
'old.dll',
]);
expect(api.downloadedPaths, ['MoonWell.exe', 'Data/common.MPQ']);
expect(statuses.last.message, 'Клиент готов к игре.');
expect(logger.errorMessages, isEmpty);
},
);
test('cleans an up-to-date installation without downloading', () async {
final root = await Directory.systemTemp.createTemp('moonwell_sync_');
addTearDown(() => root.delete(recursive: true));
final service = GameInstallationService();
final retained = ['MoonWell.exe', 'Data/common.MPQ', 'required.dll'];
final obsolete = ['Data/old/patch.MPQ', 'WoW.exe', 'unused.DLL'];
for (final path in [
...retained,
...obsolete,
'WTF/Config.wtf',
'realmlist.wtf',
]) {
final file = File(service.resolveClientPath(root.path, path));
await file.parent.create(recursive: true);
await file.writeAsString(path);
}
final entries = <ClientManifestFile>[];
for (final path in retained) {
final filePath = service.resolveClientPath(root.path, path);
entries.add(
ClientManifestFile(
path: path,
size: (await File(filePath).stat()).size,
sha256: await service.computeSha256(filePath),
),
);
}
final manifest = _manifestFromFiles(entries);
final api = _FakeLauncherApiClient(manifest);
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: service,
launcherLogService: _FakeLauncherLogService(),
);
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: root.path,
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.localBuildHash, manifest.buildHash);
expect(
statuses.map((status) => status.stage),
contains(ClientSyncStage.removingStaleFiles),
);
expect(api.downloadedPaths, isEmpty);
for (final path in [...obsolete, 'Data/old']) {
expect(
await FileSystemEntity.type(
service.resolveClientPath(root.path, path),
followLinks: false,
),
FileSystemEntityType.notFound,
reason: path,
);
}
for (final path in [...retained, 'WTF/Config.wtf', 'realmlist.wtf']) {
expect(
await File(service.resolveClientPath(root.path, path)).readAsString(),
path,
);
}
});
test('fails final verification when stale files remain', () async {
final manifest = _manifestFromFiles([
const ClientManifestFile(path: 'MoonWell.exe', size: 5, sha256: 'hash'),
]);
final snapshot = ClientInstallationSnapshot.fromFiles(
[const LocalClientFile(path: 'MoonWell.exe', size: 5, sha256: 'hash')],
stalePaths: ['Data/extra.MPQ'],
);
final service = _FakeGameInstallationService([snapshot, snapshot]);
final useCase = ClientSyncUseCase(
launcherApiClient: _FakeLauncherApiClient(manifest),
installationService: service,
launcherLogService: _FakeLauncherLogService(),
);
await expectLater(
useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.drain<void>(),
throwsA(
isA<LauncherSyncException>().having(
(error) => error.message,
'message',
contains('Data/extra.MPQ'),
),
),
);
});
test(
'preserves failed temporary file and returns a log hint on verification mismatch',
@@ -244,7 +370,10 @@ class _FakeLauncherApiClient extends LauncherApiClient {
}
class _FakeGameInstallationService extends GameInstallationService {
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
: _snapshots = Queue.of(snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = [];
@@ -254,34 +383,35 @@ class _FakeGameInstallationService extends GameInstallationService {
final Map<String, String> computedHashesByPath = {};
final Map<String, int?> fileSizesByPath = {};
// @override
// Future<ClientInstallationSnapshot> scanInstallation(
// String installationDir, {
// InstallationScanProgressCallback? onProgress,
// FutureOr<bool> Function()? isCancelled,
// }) async {
// final snapshot = _snapshots.removeFirst();
// var processedFiles = 0;
// var processedBytes = 0;
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
final snapshot = _snapshots.removeFirst();
var processedFiles = 0;
var processedBytes = 0;
// for (final file in snapshot.files) {
// processedFiles += 1;
// processedBytes += file.size;
// onProgress?.call(
// DownloadProgress(
// speed: 0,
// downloaded: processedBytes,
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
// eta: Duration.zero,
// ),
// file.path,
// processedFiles,
// snapshot.files.length,
// );
// }
for (final file in snapshot.files) {
processedFiles += 1;
processedBytes += file.size;
onProgress?.call(
DownloadProgress(
speed: 0,
downloaded: processedBytes,
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero,
),
file.path,
processedFiles,
snapshot.files.length,
);
}
// return snapshot;
// }
return snapshot;
}
@override
Future<void> deleteRelativeFile(
@@ -1,9 +1,9 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
// import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
@@ -52,37 +52,278 @@ void main() {
}
});
// test('scanInstallation ignores excluded directories', () async {
// final snapshot = await service.scanInstallation(rootDirectory.path);
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
test(
'finds only unmanaged Data files and legacy client binaries',
() async {
final retainedPaths = [
'MoonWell.exe',
'Data/common.MPQ',
'Data/ruRU/realmlist.wtf',
'Data/Addons/required.MPQ',
'required.DLL',
'WoW/required.dll',
'WoW/Data/common.MPQ',
];
final staleFiles = [
'Data/old.MPQ',
'Data/ruRU/old.MPQ',
'Data/Addons/extra.MPQ',
'Data/Cache/old.bin',
'Data/empty/old.MPQ',
'Data/common.MPQ.moonwell.part',
'Data/broken.MPQ.moonwell.part.failed',
'WoW.exe',
'old.DLL',
'WoW/WoW.exe',
'WoW/old.dll',
'WoW/Data/old.MPQ',
];
final untouchedPaths = [
'Cache/cache.bin',
'Logs/launcher.log',
'WTF/Config.wtf',
'Errors/error.dll',
'Screenshots/shot.dll',
'benilla-config/settings.dll',
'.moonwell_launcher/local.dll',
'Interface/AddOns/custom/addon.dll',
'notes.txt',
'Other.exe',
'realmlist.wtf',
];
for (final path in [
...retainedPaths,
...staleFiles,
...untouchedPaths,
]) {
final file = File(
service.resolveClientPath(rootDirectory.path, path),
);
await file.parent.create(recursive: true);
await file.writeAsString(path);
}
await Directory(
service.resolveClientPath(rootDirectory.path, 'Data/unused/nested'),
).create(recursive: true);
final manifest = await _manifestForPaths(
service,
rootDirectory,
retainedPaths,
);
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
// });
final snapshot = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
// test(
// 'scanInstallation creates launcher hash cache outside verification set',
// () async {
// await service.scanInstallation(rootDirectory.path);
expect(
snapshot.files.map((file) => file.path),
unorderedEquals(retainedPaths),
);
expect(snapshot.buildHash, manifest.buildHash);
expect(
snapshot.stalePaths,
unorderedEquals([
...staleFiles,
'Data/Cache',
'Data/empty',
'Data/unused/nested',
'Data/unused',
]),
);
for (final path in snapshot.stalePaths) {
await service.deleteRelativeFile(rootDirectory.path, path);
}
for (final path in [...retainedPaths, ...untouchedPaths]) {
expect(
await File(
service.resolveClientPath(rootDirectory.path, path),
).exists(),
isTrue,
reason: path,
);
}
for (final path in snapshot.stalePaths) {
expect(
await FileSystemEntity.type(
service.resolveClientPath(rootDirectory.path, path),
followLinks: false,
),
FileSystemEntityType.notFound,
reason: path,
);
}
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
// expect(await cacheFile.exists(), isTrue);
final verified = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
expect(verified.stalePaths, isEmpty);
expect(verified.buildHash, manifest.buildHash);
expect(
await File(service.getHashCachePath(rootDirectory.path)).exists(),
isTrue,
);
},
);
// final secondSnapshot = await service.scanInstallation(
// rootDirectory.path,
// );
// final paths = secondSnapshot.files.map((file) => file.path).toList()
// ..sort();
test('removes Data links without touching their targets', () async {
final outside = await Directory.systemTemp.createTemp(
'moonwell_outside_',
);
addTearDown(() => outside.delete(recursive: true));
final outsideFile = File(
'${outside.path}${Platform.pathSeparator}keep.dll',
);
await outsideFile.writeAsString('keep');
final link = Link(
service.resolveClientPath(rootDirectory.path, 'Data/external'),
);
await link.create(outside.path);
final manifest = await _manifestForPaths(service, rootDirectory, [
'Data/common.MPQ',
]);
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
// expect(
// await Directory(
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
// ).exists(),
// isTrue,
// );
// },
// );
final snapshot = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
expect(snapshot.stalePaths, ['Data/external']);
await service.deleteRelativeFile(rootDirectory.path, 'Data/external');
expect(await link.exists(), isFalse);
expect(await outsideFile.readAsString(), 'keep');
});
test('retains a directory link needed by manifest files', () async {
final target = Directory(
service.resolveClientPath(rootDirectory.path, 'shared-data'),
);
await target.create();
await File(
'${target.path}${Platform.pathSeparator}required.MPQ',
).writeAsString('required');
final link = Link(
service.resolveClientPath(rootDirectory.path, 'Data/linked'),
);
await link.create(target.path);
final manifest = await _manifestForPaths(service, rootDirectory, [
'Data/common.MPQ',
'Data/linked/required.MPQ',
]);
final snapshot = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
expect(snapshot.stalePaths, isEmpty);
expect(snapshot.buildHash, manifest.buildHash);
expect(await link.exists(), isTrue);
});
test(
'rejects deleting through a directory link outside installation',
() async {
final outside = await Directory.systemTemp.createTemp(
'moonwell_outside_',
);
addTearDown(() => outside.delete(recursive: true));
final outsideFile = File(
'${outside.path}${Platform.pathSeparator}keep.dll',
);
await outsideFile.writeAsString('keep');
final link = Link(
service.resolveClientPath(rootDirectory.path, 'Data/external'),
);
await link.create(outside.path);
await expectLater(
service.deleteRelativeFile(
rootDirectory.path,
'Data/external/keep.dll',
),
throwsA(isA<LauncherSyncException>()),
);
expect(await outsideFile.readAsString(), 'keep');
},
);
test(
'preserves Windows manifest files despite path case differences',
() async {
final file = File(
service.resolveClientPath(
rootDirectory.path,
'Data/ruRU/required.DLL',
),
);
await file.parent.create(recursive: true);
await file.writeAsString('required');
final manifest = await _manifestForPaths(service, rootDirectory, [
'data/ruru/required.dll',
]);
final snapshot = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
expect(snapshot.stalePaths, ['Data/common.MPQ']);
expect(snapshot.buildHash, manifest.buildHash);
},
skip: !Platform.isWindows,
);
test('preserves WoW.exe when explicitly present in manifest', () async {
await File(
service.resolveClientPath(rootDirectory.path, 'WoW.exe'),
).writeAsString('legacy');
final manifest = await _manifestForPaths(service, rootDirectory, [
'WoW.exe',
'Data/common.MPQ',
]);
final snapshot = await service.scanInstallation(
rootDirectory.path,
manifest: manifest,
);
expect(snapshot.stalePaths, isEmpty);
expect(snapshot.buildHash, manifest.buildHash);
});
test('rejects invalid manifest paths before scanning', () async {
for (final path in [
'../outside.dll',
'/outside.dll',
r'C:\outside.dll',
'C:outside.dll',
'Data/file:stream',
]) {
final manifest = ClientManifest.fromJson({
'files': [
{'path': path, 'size': 0, 'sha256': ''},
],
});
await expectLater(
service.scanInstallation(rootDirectory.path, manifest: manifest),
throwsA(isA<LauncherSyncException>()),
);
}
expect(
await File(service.getHashCachePath(rootDirectory.path)).exists(),
isFalse,
);
});
test(
'never recursively deletes a stale directory with new files',
() async {
final file = File(
service.resolveClientPath(rootDirectory.path, 'Data/old/new.MPQ'),
);
await file.parent.create(recursive: true);
await file.writeAsString('new');
await expectLater(
service.deleteRelativeFile(rootDirectory.path, 'Data/old'),
throwsA(isA<FileSystemException>()),
);
expect(await file.readAsString(), 'new');
},
);
test('clearCache recreates an empty cache directory', () async {
await service.clearCache(rootDirectory.path);
@@ -335,6 +576,25 @@ void main() {
});
}
Future<ClientManifest> _manifestForPaths(
GameInstallationService service,
Directory rootDirectory,
List<String> paths,
) async {
final files = <Map<String, Object>>[];
for (final path in paths) {
final bytes = await File(
service.resolveClientPath(rootDirectory.path, path),
).readAsBytes();
files.add({
'path': path,
'size': bytes.length,
'sha256': sha256.convert(bytes).toString(),
});
}
return ClientManifest.fromJson({'files': files});
}
LauncherGameTicket _validTicket() => LauncherGameTicket(
account: 'PLAYERONE',
ticket: 'A1B2C3D4E5F6G7H8',