diff --git a/ui/src/app/services/config/config-mediated.spec.ts b/ui/src/app/services/config/config-mediated.spec.ts index 4113073..dfbe904 100644 --- a/ui/src/app/services/config/config-mediated.spec.ts +++ b/ui/src/app/services/config/config-mediated.spec.ts @@ -1075,6 +1075,162 @@ describe('ConfigService (mediated data plane)', () => { errorSpy.mockRestore(); }); + it('leaves projectConfig.error() undefined after load failure, setting projectLoadError instead', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + return throwError(() => new HttpErrorResponse({status: 500})); + }); + + service.loadProjectConfig('proj-err', 'full'); + await vi.waitFor(() => { + expect(service.projectLoadError()).toBe(true); + }); + + // rxResource catches the error and degrades to DEFAULT_PROJECT_CONFIG, + // so projectConfig.error() is permanently undefined. + expect(service.projectConfig.error()).toBeUndefined(); + expect(service.setupInputsError()).toBe(true); + expect(service.setupInputsLoaded()).toBe(false); + errorSpy.mockRestore(); + }); + + it('evaluates setupInputsLoaded to false when projectLoadError is set even if id matches', () => { + service.projectConfig.value.set({ + ...service.projectConfig.value(), + id: 'proj-matched', + }); + service.loadProjectConfig('proj-matched', 'full'); + expect(service.setupInputsLoaded()).toBe(true); + + // When a load error is recorded, setupInputsLoaded must be false even though + // value().id === projectId() is true. + (service as any).projectLoadErrorValue.set(new Error('load failed')); + expect(service.projectLoadError()).toBe(true); + expect(service.setupInputsLoaded()).toBe(false); + }); + + it('evaluates setupInputsLoaded to false on 404 with in-flight autosave to prevent destructive full PATCH', async () => { + const editorProject = { + ...service.projectConfig.value(), + id: 'proj-hydrate', + inputConfig: undefined, + }; + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url.includes('/api/projects/proj-hydrate')) { + if (url.endsWith('?view=editor')) { + return of(editorProject); + } + return throwError(() => new HttpErrorResponse({status: 404})); + } + return of({}); + }); + + service.loadProjectConfig('proj-hydrate', 'editor'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('proj-hydrate'); + }); + markPersisted('proj-hydrate'); + + const inFlightPatch = new Subject(); + httpClientMock.patch.mockReturnValue(inFlightPatch); + service.updateProjectConfig({name: 'in-flight'}); + service.saveNow(); + + service.loadProjectConfig('proj-hydrate', 'full'); + await vi.waitFor(() => { + expect(httpClientMock.get).toHaveBeenCalledWith( + '/api/projects/proj-hydrate', + ); + }); + await Promise.resolve(); + await Promise.resolve(); + + // On 404 with localProjectAtLoad, the loader preserves local latestSource so the user + // is not disrupted, but setupInputsLoaded MUST be false because full server inputs were not hydrated. + expect(service.setupInputsLoaded()).toBe(false); + expect(service.projectConfig.value().id).toBe('proj-hydrate'); + expect(service.projectConfig.value().inputConfig).toBeUndefined(); + expect(service.setupInputsError()).toBe(true); + expect(service.projectLoadError()).toBe(false); + }); + + it('navigates home and resets project on 404 without local copy', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + return throwError(() => new HttpErrorResponse({status: 404})); + }); + + service.loadProjectConfig('non-existent', 'full'); + await vi.waitFor(() => { + expect(httpClientMock.get).toHaveBeenCalledWith( + '/api/projects/non-existent', + ); + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(service.setupInputsLoaded()).toBe(false); + expect(service.projectConfig.value().id).toBe(''); + expect(TestBed.inject(Router).navigate).toHaveBeenCalledWith(['/']); + errorSpy.mockRestore(); + }); + + it('yields setupInputsLoaded === true on normal successful load', async () => { + const fullProject = { + ...service.projectConfig.value(), + id: 'proj-ok', + inputConfig: {products: [], composition: 'ok'}, + }; + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url === '/api/projects/proj-ok') return of(fullProject); + return of({}); + }); + + service.loadProjectConfig('proj-ok', 'full'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('proj-ok'); + }); + + expect(service.projectLoadError()).toBe(false); + expect(service.setupInputsLoaded()).toBe(true); + }); + + it('keeps a locally created project ready after leaving an editor route or failed load', async () => { + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url === '/api/projects/project-a?view=editor') { + return of({...service.projectConfig.value(), id: 'project-a'}); + } + return of({}); + }); + service.loadProjectConfig('project-a', 'editor'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('project-a'); + }); + (service as any).projectLoadErrorValue.set(new Error('stale failure')); + httpClientMock.get.mockClear(); + + service.resetProjectConfig(); + service.setNewProject('project-b'); + expect(service.setupInputsLoaded()).toBe(true); + service.saveNow(); + + service.loadProjectConfig('project-b', 'full'); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(httpClientMock.get).not.toHaveBeenCalledWith( + '/api/projects/project-b', + ); + expect(service.projectLoadError()).toBe(false); + expect(service.setupInputsError()).toBe(false); + expect(service.setupInputsLoaded()).toBe(true); + expect(service.projectConfig.value().id).toBe('project-b'); + }); + it('keeps a locally created full project ready after leaving another project', () => { service.projectConfig.value.set({ ...service.projectConfig.value(), diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index b8ef880..eb0c0f5 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -644,6 +644,7 @@ export class ConfigService { private projectId = signal(null); private projectView = signal('full'); private projectLoadErrorValue = signal(undefined); + private setupInputsHydrated = signal(false); /** * Mediated mode only: ids known to exist server-side (loaded via GET or * already POSTed). First save of a new project goes through @@ -946,6 +947,7 @@ export class ConfigService { loader: async ({params, abortSignal}) => { if (params.projectId === null) { this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(false); return {...this.DEFAULT_PROJECT_CONFIG()}; } const isCurrentLoad = () => @@ -954,6 +956,7 @@ export class ConfigService { this.projectView() === params.view; if (isCurrentLoad()) { this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(false); } const localProjectAtLoad = this.projectWithUnsettledSave( params.projectId, @@ -967,6 +970,9 @@ export class ConfigService { if (isCurrentLoad()) { this.projectLoadErrorValue.set(undefined); this.persistedProjectIds.add(params.projectId); + if (params.view === 'full') { + this.setupInputsHydrated.set(true); + } } if (localProjectAtLoad) { const latest = @@ -1021,16 +1027,32 @@ export class ConfigService { (!!this.projectLoadErrorValue() || !!this.projectConfig.error()), ); readonly setupInputsError = computed( - () => this.projectView() === 'full' && this.projectLoadError(), - ); - readonly setupInputsLoaded = computed( () => this.projectView() === 'full' && - !this.projectConfig.isLoading() && - !this.projectConfig.error() && - (this.projectConfig.value().id === this.projectId() || - (this.projectId() === null && !!this.projectConfig.value().id)), + (this.projectLoadError() || + (this.projectId() !== null && + !this.projectConfig.isLoading() && + this.projectConfig.value().id === this.projectId() && + !this.setupInputsHydrated())), ); + /** + * Setup may expose its input fields only when the current project's + * inputConfig came from a successful full server load, or the project was + * created locally. + */ + readonly setupInputsLoaded = computed(() => { + const isLocalNewProject = + this.projectId() === null && !!this.projectConfig.value().id; + const isHydratedServerProject = + this.setupInputsHydrated() && + this.projectConfig.value().id === this.projectId(); + return ( + this.projectView() === 'full' && + !this.projectConfig.isLoading() && + !this.projectLoadError() && + (isLocalNewProject || isHydratedServerProject) + ); + }); private normalizeLoadedProject(data: ProjectConfig): ProjectConfig { if (data.renderRuns) { @@ -1491,7 +1513,10 @@ export class ConfigService { // would otherwise be silently dropped once the config resets (the // post-reset emission has id === '' / shouldSave === false). this.flushPendingSave(); + this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(false); this.projectId.set(null); + this.projectView.set('full'); this.projectConfig.set({...this.DEFAULT_PROJECT_CONFIG()}); this.shouldSave = false; } @@ -1510,6 +1535,9 @@ export class ConfigService { // Not persisted yet: the first autosave POSTs /api/projects, where the // server stamps createdBy from the verified identity. Left undefined here. this.persistedProjectIds.delete(uuid); + this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(true); + this.projectView.set('full'); const project = { ...this.DEFAULT_PROJECT_CONFIG(), id: uuid, diff --git a/ui/src/app/setup/setup.html b/ui/src/app/setup/setup.html index 77337ce..b22d94c 100644 --- a/ui/src/app/setup/setup.html +++ b/ui/src/app/setup/setup.html @@ -20,6 +20,7 @@ + Back to projects } @else if (setupLoading() || !setupReady()) {
diff --git a/ui/src/app/setup/setup.spec.ts b/ui/src/app/setup/setup.spec.ts index 5ee3f4e..0206f64 100644 --- a/ui/src/app/setup/setup.spec.ts +++ b/ui/src/app/setup/setup.spec.ts @@ -123,6 +123,121 @@ describe('Setup full-load failure', () => { fixture.nativeElement.querySelector('.setup-container'), ).not.toBeNull(); }); + + it('does not show an error before a project load or while the load is pending', () => { + expect(config.setupInputsError()).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain( + 'Could not load this project', + ); + config.loadProjectConfig('pending-project', 'full'); + expect(config.setupInputsError()).toBe(false); + TestBed.tick(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('mat-spinner')).not.toBeNull(); + expect(config.setupInputsError()).toBe(false); + http + .expectOne('/api/projects/pending-project') + .flush('failed', {status: 500, statusText: 'Server Error'}); + }); + + it('offers recovery without blank inputs or full PATCH after both load and pending save return 404', async () => { + ( + config as unknown as {persistedProjectIds: Set} + ).persistedProjectIds.add('proj-unsettled'); + + config.loadProjectConfig('proj-unsettled', 'editor'); + TestBed.tick(); + const editorReq = http.expectOne( + '/api/projects/proj-unsettled?view=editor', + ); + editorReq.flush({ + id: 'proj-unsettled', + name: 'Editor Project', + aspectRatio: '16:9', + resolution: '720p', + candidateDurationSeconds: 4, + generateAudio: false, + numberOfCandidates: 1, + model: 'veo-default', + inputConfig: undefined, + storyboard: [], + audioTracks: [], + visualOverlays: [], + }); + TestBed.tick(); + await fixture.whenStable(); + + config.updateProjectConfig({name: 'In-Flight'}); + config.saveNow(); + const inFlightPatch = http.expectOne('/api/projects/proj-unsettled/editor'); + + config.loadProjectConfig('proj-unsettled', 'full'); + expect(config.setupInputsError()).toBe(false); + TestBed.tick(); + expect(config.setupInputsError()).toBe(false); + const fullGet = http.expectOne('/api/projects/proj-unsettled'); + fullGet.flush('Not found', {status: 404, statusText: 'Not Found'}); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.setupInputsLoaded()).toBe(false); + expect(config.projectConfig.value().inputConfig).toBeUndefined(); + http.expectNone('/api/projects/proj-unsettled'); + + inFlightPatch.flush('Not found', {status: 404, statusText: 'Not Found'}); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.projectLoadError()).toBe(false); + expect(config.setupInputsError()).toBe(true); + expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'Could not load this project', + ); + const home = fixture.nativeElement.querySelector('a[href="/"]'); + expect(home?.textContent).toContain('Back to projects'); + expect(fixture.nativeElement.querySelector('.setup-container')).toBeNull(); + expect(config.projectConfig.value().inputConfig).toBeUndefined(); + http.expectNone('/api/projects/proj-unsettled'); + + const retry = fixture.nativeElement.querySelector( + '.loading-state button', + ) as HTMLButtonElement | null; + expect(retry?.textContent).toContain('Retry'); + retry?.click(); + TestBed.tick(); + const retryGet = http.expectOne('/api/projects/proj-unsettled'); + retryGet.flush({ + id: 'proj-unsettled', + name: 'Server Name', + aspectRatio: '16:9', + resolution: '720p', + candidateDurationSeconds: 4, + generateAudio: false, + numberOfCandidates: 1, + model: 'veo-default', + inputConfig: {products: [], composition: 'Recovered composition'}, + storyboard: [], + audioTracks: [], + visualOverlays: [], + }); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.setupInputsError()).toBe(false); + expect(config.setupInputsLoaded()).toBe(true); + expect(config.projectConfig.value().name).toBe('In-Flight'); + expect(config.projectConfig.value().inputConfig).toEqual({ + products: [], + composition: 'Recovered composition', + }); + expect( + fixture.nativeElement.querySelector('.setup-container'), + ).not.toBeNull(); + }); }); describe('Setup image upload', () => {