blob: e4f8c6eb0d1e401c2adaaf9066e920648a471e4e [file]
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:dartpad_editor/dartpad_editor.dart';
import 'package:frontend/features/project/data/project_repository.dart';
import 'package:frontend/features/project/data/project_store.dart';
import 'package:frontend/features/project/data/project_workspace_snapshots.dart';
import 'package:frontend/features/project/models/persisted_project.dart';
import 'package:frontend/features/project/models/project_session.dart';
import 'package:frontend/features/project/view_models/project_session_view_model.dart';
import 'package:frontend/features/shared/logic/app_event_bus.dart';
import 'package:frontend/features/shared/project_zip_manifest.dart';
import 'package:frontend/features/workspace/data/workspace_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
class MockWorkspaceRepository extends Mock implements WorkspaceRepository {}
class MockWorkspaceResourceApi extends Mock implements WorkspaceResourceApi {}
class MockProjectSession extends Mock implements ProjectSession {}
class FakeProjectStore implements ProjectStore {
final Map<String, PersistedProject> projects = {};
@override
Future<void> deleteProject(String id) async {
projects.remove(id);
}
@override
Future<PersistedProject?> getProject(String id) async {
return projects[id];
}
@override
Future<PersistedProject?> getProjectMeta(String id) async {
final project = projects[id];
if (project == null) {
return null;
}
return project.copyWith(archive: null);
}
@override
Future<List<PersistedProject>> listProjects() async {
final list = projects.values.map((p) => p.copyWith(archive: null)).toList();
list.sort((a, b) => b.lastOpenedAt.compareTo(a.lastOpenedAt));
return list;
}
@override
Future<void> putProject(PersistedProject project) async {
projects[project.id] = project;
}
}
void main() {
setUpAll(() {
registerFallbackValue(Archive());
});
group('ProjectSessionViewModel', () {
late FakeProjectStore store;
late ProjectRepository projectRepository;
late MockWorkspaceRepository workspaceRepository;
late MockWorkspaceResourceApi mockWorkspaceResourceApi;
late StreamController<WorkspaceChangeEvent> fileEvents;
late AppCommandBus commandBus;
setUp(() {
store = FakeProjectStore();
projectRepository = DartProjectRepository(store: store);
workspaceRepository = MockWorkspaceRepository();
mockWorkspaceResourceApi = MockWorkspaceResourceApi();
fileEvents = StreamController<WorkspaceChangeEvent>.broadcast();
commandBus = AppCommandBus();
when(() => workspaceRepository.workspaceResourceApi).thenReturn(mockWorkspaceResourceApi);
when(() => mockWorkspaceResourceApi.changeEvents).thenAnswer((_) => fileEvents.stream);
when(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).thenAnswer((_) async => Archive());
});
tearDown(() async {
await fileEvents.close();
commandBus.dispose();
});
test('creates prompt projects with null name and generated id', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'generated-id',
);
final project = await viewModel.createPlaceholderProject();
expect(project.id, 'generated-id');
expect(project.name, isNull);
expect(store.projects['generated-id'], isNotNull);
viewModel.dispose();
});
test('lists recent projects by lastOpenedAt descending', () async {
final older = _project(id: 'older', lastOpenedAt: DateTime.utc(2026, 1, 1));
final newer = _project(id: 'newer', lastOpenedAt: DateTime.utc(2026, 2, 1));
await store.putProject(older);
await store.putProject(newer);
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'unused',
);
final recents = await viewModel.listRecentProjects();
expect(recents.map((project) => project.id), ['newer', 'older']);
viewModel.dispose();
});
test('closeCurrentProject cancels pending snapshots and ignores later workspace events', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'project-id',
snapshotDebounce: const Duration(seconds: 10),
);
// Wait for the constructor's microtask to register listener
await Future<void>.delayed(Duration.zero);
verify(() => workspaceRepository.workspaceResourceApi).called(1);
verify(() => mockWorkspaceResourceApi.changeEvents).called(1);
final project = await viewModel.createPlaceholderProject();
verify(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).called(1);
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
// Workspace event schedules timer
fileEvents.add(WorkspaceChangeEvent(type: WorkspaceChangeEventType.modify, path: 'lib/main.dart'));
await Future<void>.delayed(Duration.zero);
// Verify that it is scheduled and pending
expect(viewModel.hasPendingSnapshot, isTrue);
// Close project cancels pending timer and listener
viewModel.closeCurrentProject();
// Verify that it is no longer pending
expect(viewModel.hasPendingSnapshot, isFalse);
// Wait some time to be absolutely sure no snapshot is triggered
await Future<void>.delayed(const Duration(milliseconds: 50));
verifyNoMoreInteractions(workspaceRepository);
// Further events ignored
fileEvents.add(WorkspaceChangeEvent(type: WorkspaceChangeEventType.modify, path: 'lib/main.dart'));
await Future<void>.delayed(Duration.zero);
expect(viewModel.hasPendingSnapshot, isFalse);
verifyNoMoreInteractions(workspaceRepository);
viewModel.dispose();
});
test('persists a follow-up snapshot when workspace changes during persistence', () async {
final snapshots = <Completer<Archive>>[];
when(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).thenAnswer((_) {
final completer = Completer<Archive>();
snapshots.add(completer);
return completer.future;
});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'project-id',
snapshotDebounce: const Duration(milliseconds: 1),
);
// Wait for constructor microtask
await Future<void>.delayed(Duration.zero);
final createFuture = viewModel.createPlaceholderProject();
await Future<void>.delayed(Duration.zero);
snapshots.single.complete(Archive());
final project = await createFuture;
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
final persistFuture = viewModel.persistCurrentSnapshotNow();
var persistCompleted = false;
unawaited(persistFuture.whenComplete(() => persistCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(snapshots, hasLength(2));
expect(viewModel.isPersistingSnapshot, isTrue);
expect(viewModel.hasPendingSnapshot, isFalse);
fileEvents.add(WorkspaceChangeEvent(type: WorkspaceChangeEventType.modify, path: 'lib/main.dart'));
await Future<void>.delayed(Duration.zero);
expect(viewModel.isPersistingSnapshot, isTrue);
expect(viewModel.hasPendingSnapshot, isTrue);
snapshots[1].complete(Archive());
await Future<void>.delayed(Duration.zero);
expect(snapshots, hasLength(3));
expect(viewModel.isPersistingSnapshot, isTrue);
expect(persistCompleted, isFalse);
snapshots[2].complete(Archive());
await persistFuture;
await Future<void>.delayed(Duration.zero);
expect(store.projects['project-id']?.archive, isNotNull);
expect(viewModel.isPersistingSnapshot, isFalse);
expect(viewModel.hasPendingSnapshot, isFalse);
viewModel.dispose();
});
test('awaits explicit coalesced snapshot requests until the follow-up save finishes', () async {
final snapshots = <Completer<Archive>>[];
when(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).thenAnswer((_) {
final completer = Completer<Archive>();
snapshots.add(completer);
return completer.future;
});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'project-id',
);
// Wait for constructor microtask
await Future<void>.delayed(Duration.zero);
final createFuture = viewModel.createPlaceholderProject();
await Future<void>.delayed(Duration.zero);
snapshots.single.complete(Archive());
final project = await createFuture;
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
final firstPersist = viewModel.persistCurrentSnapshotNow();
await Future<void>.delayed(Duration.zero);
expect(snapshots, hasLength(2));
final secondPersist = viewModel.persistCurrentSnapshotNow();
var secondCompleted = false;
unawaited(secondPersist.whenComplete(() => secondCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(viewModel.hasPendingSnapshot, isTrue);
expect(secondCompleted, isFalse);
snapshots[1].complete(Archive());
await Future<void>.delayed(Duration.zero);
expect(snapshots, hasLength(3));
expect(viewModel.hasPendingSnapshot, isTrue);
expect(secondCompleted, isFalse);
snapshots[2].complete(Archive());
await secondPersist;
await firstPersist;
await Future<void>.delayed(Duration.zero);
expect(secondCompleted, isTrue);
expect(store.projects['project-id']?.archive, isNotNull);
expect(viewModel.hasPendingSnapshot, isFalse);
viewModel.dispose();
});
test('imports manifest collision as copy with a new id', () async {
await store.putProject(_project(id: 'existing', name: 'Existing'));
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'copy-id',
);
final imported = await viewModel.importProjectFromZip(
_zipBytesWithManifest(projectId: 'existing', name: 'Imported'),
fileName: 'project.zip',
resolveCollision: (_) async => ProjectImportCollisionChoice.importAsCopy,
);
expect(imported?.id, 'copy-id');
expect(imported?.name, 'Imported copy');
expect(store.projects['existing']?.name, 'Existing');
expect(store.projects['copy-id'], isNotNull);
viewModel.dispose();
});
test('imports manifest collision by replacing the existing project', () async {
await store.putProject(_project(id: 'existing', name: 'Existing'));
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'unused',
);
final imported = await viewModel.importProjectFromZip(
_zipBytesWithManifest(projectId: 'existing', name: 'Imported'),
fileName: 'project.zip',
resolveCollision: (_) async => ProjectImportCollisionChoice.replaceExisting,
);
expect(imported?.id, 'existing');
expect(imported?.name, 'Imported');
expect(store.projects.keys, ['existing']);
viewModel.dispose();
});
test('cancels manifest collision import without changing the store', () async {
await store.putProject(_project(id: 'existing', name: 'Existing'));
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'copy-id',
);
final imported = await viewModel.importProjectFromZip(
_zipBytesWithManifest(projectId: 'existing', name: 'Imported'),
fileName: 'project.zip',
resolveCollision: (_) async => ProjectImportCollisionChoice.cancel,
);
expect(imported, isNull);
expect(store.projects.keys, ['existing']);
verifyNever(() => workspaceRepository.importProjectFromArchive(any()));
viewModel.dispose();
});
test('openProject loads archive into workspace and updates lastOpenedAt', () async {
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'unused',
);
final originalTime = DateTime.utc(2026, 1, 1);
final project = _project(id: 'open-me', lastOpenedAt: originalTime);
await store.putProject(project);
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
final opened = await viewModel.openProject(project);
expect(opened, isNotNull);
expect(opened.id, 'open-me');
expect(opened.lastOpenedAt.isAfter(originalTime), isTrue);
verify(() => workspaceRepository.importProjectFromArchive(any())).called(1);
expect(store.projects['open-me']!.lastOpenedAt.isAfter(originalTime), isTrue);
viewModel.dispose();
});
test('openProject throws when archive is null', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'unused',
);
final metaOnly = _project(id: 'no-archive').copyWith(archive: null);
await expectLater(
viewModel.openProject(metaOnly),
throwsStateError,
);
viewModel.dispose();
});
test('renameCurrentProject updates name and rebuilds snapshot', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'rename-id',
);
final project = await viewModel.createPlaceholderProject(name: 'Old Name');
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
await viewModel.renameCurrentProject('New Name');
final saved = store.projects['rename-id'];
expect(saved, isNotNull);
expect(saved!.name, 'New Name');
// buildProjectArchive called once for createPlaceholder, once for rename.
verify(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).called(2);
viewModel.dispose();
});
test('renameCurrentProject ignores empty or unchanged name', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'rename-noop-id',
);
final project = await viewModel.createPlaceholderProject(name: 'Same');
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
await viewModel.renameCurrentProject('Same');
await viewModel.renameCurrentProject('');
await viewModel.renameCurrentProject(' ');
// buildProjectArchive called only once (createPlaceholder), not for any rename.
verify(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).called(1);
viewModel.dispose();
});
test('generateAndSaveProjectTitle generates and applies title', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'title-id',
titleGenerator: (prompt) async => 'Generated Title',
);
final project = await viewModel.createPlaceholderProject();
expect(project.name, isNull);
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
await viewModel.generateAndSaveProjectTitle('build me an app');
final saved = store.projects['title-id'];
expect(saved!.name, 'Generated Title');
viewModel.dispose();
});
test('generateAndSaveProjectTitle falls back to Untitled on error', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'title-err-id',
titleGenerator: (prompt) async => throw Exception('API error'),
);
final project = await viewModel.createPlaceholderProject();
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
await viewModel.generateAndSaveProjectTitle('build me an app');
final saved = store.projects['title-err-id'];
expect(saved!.name, 'Untitled Project');
viewModel.dispose();
});
test('persistCurrentSnapshotNow propagates error to pending completers', () async {
final snapshots = <Completer<Archive>>[];
when(
() => workspaceRepository.buildProjectArchive(manifest: any(named: 'manifest')),
).thenAnswer((_) {
final completer = Completer<Archive>();
snapshots.add(completer);
return completer.future;
});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'error-id',
);
await Future<void>.delayed(Duration.zero);
final createFuture = viewModel.createPlaceholderProject();
await Future<void>.delayed(Duration.zero);
snapshots.single.complete(Archive());
final project = await createFuture;
final session = MockProjectSession();
when(() => session.project).thenReturn(project);
viewModel.session = session;
final firstPersist = viewModel.persistCurrentSnapshotNow();
await Future<void>.delayed(Duration.zero);
// Second request arrives while first is in flight.
final secondPersist = viewModel.persistCurrentSnapshotNow();
// First snapshot build fails.
final error = StateError('disk full');
snapshots[1].completeError(error);
await expectLater(firstPersist, throwsA(same(error)));
await expectLater(secondPersist, throwsA(same(error)));
expect(viewModel.isPersistingSnapshot, isFalse);
expect(viewModel.hasPendingSnapshot, isFalse);
viewModel.dispose();
});
test('creates placeholder project with provided name', () async {
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'named-id',
);
final project = await viewModel.createPlaceholderProject(name: 'My App');
expect(project.id, 'named-id');
expect(project.name, 'My App');
expect(project.archive, isNotNull);
expect(store.projects['named-id'], isNotNull);
viewModel.dispose();
});
test('imports a ZIP without VibePad manifest using generated id and filename', () async {
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'gen-import-id',
);
final archive = Archive()..addFile(ArchiveFile.bytes('pubspec.yaml', utf8.encode('name: imported_app')));
final zipBytes = Uint8List.fromList(ZipEncoder().encode(archive));
final imported = await viewModel.importProjectFromZip(
zipBytes,
fileName: 'my-cool-app.zip',
resolveCollision: (_) async => throw StateError('should not be called'),
);
expect(imported, isNotNull);
expect(imported!.id, 'gen-import-id');
expect(imported.name, 'my-cool-app');
expect(store.projects['gen-import-id'], isNotNull);
viewModel.dispose();
});
test('imports ZIP with empty filename as Imported Project', () async {
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'empty-name-id',
);
final archive = Archive()..addFile(ArchiveFile.bytes('pubspec.yaml', utf8.encode('name: test')));
final zipBytes = Uint8List.fromList(ZipEncoder().encode(archive));
final imported = await viewModel.importProjectFromZip(
zipBytes,
fileName: '',
resolveCollision: (_) async => throw StateError('should not be called'),
);
expect(imported!.name, 'Imported Project');
viewModel.dispose();
});
test('imports ZIP with .zip-only filename as Imported Project', () async {
when(() => workspaceRepository.importProjectFromArchive(any())).thenAnswer((_) async {});
final viewModel = ProjectSessionViewModel(
projectRepository: projectRepository,
workspaceSnapshotsFuture: Future.value(workspaceRepository),
commandBus: commandBus,
idGenerator: () => 'dotzip-id',
);
final archive = Archive()..addFile(ArchiveFile.bytes('pubspec.yaml', utf8.encode('name: test')));
final zipBytes = Uint8List.fromList(ZipEncoder().encode(archive));
final imported = await viewModel.importProjectFromZip(
zipBytes,
fileName: '.zip',
resolveCollision: (_) async => throw StateError('should not be called'),
);
expect(imported!.name, 'Imported Project');
viewModel.dispose();
});
});
}
PersistedProject _project({
required String id,
String name = 'Project',
DateTime? lastOpenedAt,
}) {
final now = DateTime.utc(2026, 6, 22);
return PersistedProject(
id: id,
name: name,
createdAt: now,
updatedAt: now,
lastOpenedAt: lastOpenedAt ?? now,
archive: Archive(),
);
}
/// Builds valid ZIP bytes containing a VibePad manifest for import tests.
Uint8List _zipBytesWithManifest({required String projectId, required String name}) {
final manifest = ProjectZipManifest(
projectId: projectId,
name: name,
exportedAt: DateTime.utc(2026),
);
final archive = Archive()
..addFile(ArchiveFile.bytes('.vibepad/project.json', utf8.encode(manifest.encode())))
..addFile(ArchiveFile.bytes('pubspec.yaml', utf8.encode('name: test')));
return Uint8List.fromList(ZipEncoder().encode(archive));
}