| 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)); |
| } |