fix: complete loading review coverage (#18)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 0f68a91c5d
commit a8a3d7a747
8 files changed
+107 -52

No files matched your search

+22
View File
@@ -17,6 +17,8 @@ describe('App', () => {
beforeEach(() => {
status.set('checking');
session.isLoggedIn.set(false);
session.username.set('');
session.email.set('');
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
@@ -41,4 +43,24 @@ describe('App', () => {
expect(session.refresh).toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
});
it('renders the sign-in page for an anonymous session', () => {
status.set('anonymous');
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-sign-in-page')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).toBeNull();
});
it('renders the workspace shell for an authenticated session', () => {
status.set('authenticated');
session.isLoggedIn.set(true);
session.username.set('Camille');
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
});
});
@@ -64,4 +64,25 @@ describe('ApartmentForm', () => {
'Impossible d’enregistrer cet appartement.',
);
});
it('preserves entered values after a save fails', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
const form = fixture.componentInstance;
form.name.set('T2 rénové');
form.address.set('45 rue des Entrepreneurs');
form.purchasePrice.set(265000);
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name=name]').value).toBe('T2 rénové');
expect(fixture.nativeElement.querySelector('input[name=address]').value).toBe(
'45 rue des Entrepreneurs',
);
expect(fixture.nativeElement.textContent).toContain(
'Impossible d’enregistrer cet appartement.',
);
});
});
@@ -53,6 +53,16 @@ describe('ApartmentsPage', () => {
expect(fixture.nativeElement.textContent).not.toContain('Aucun appartement enregistré');
});
it('shows the empty state only after a successful empty load', () => {
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
const fixture = TestBed.createComponent(ApartmentsPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Aucun appartement enregistré');
expect(fixture.nativeElement.querySelector('.skeleton-card')).toBeNull();
expect(fixture.nativeElement.querySelector('.load-error')).toBeNull();
});
it('keeps established apartments visible during a background refresh', () => {
workspace.apartments.set([
{
@@ -1,5 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { finalize, Observable } from 'rxjs';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
@Injectable({ providedIn: 'root' })
export class FileDownloadService {
@@ -17,9 +18,9 @@ export class FileDownloadService {
download(key: string, request: Observable<Blob>, fileName: string, errorMessage: string): void {
if (this.pendingKeys().has(key)) return;
this.pendingKeys.update((keys) => new Set(keys).add(key));
this.errors.update((errors) => this.withoutKey(errors, key));
this.errors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(finalize(() => this.pendingKeys.update((keys) => this.without(keys, key))))
.pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
next: (blob) => this.save(blob, fileName),
error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })),
@@ -34,16 +35,4 @@ export class FileDownloadService {
link.click();
URL.revokeObjectURL(url);
}
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
const next = new Set(values);
next.delete(value);
return next;
}
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
const next = { ...record };
delete next[key];
return next;
}
}
@@ -0,0 +1,11 @@
export function withoutSetValue<T>(values: ReadonlySet<T>, value: T): ReadonlySet<T> {
const next = new Set(values);
next.delete(value);
return next;
}
export function withoutRecordKey<T>(record: Record<string, T>, key: string): Record<string, T> {
const next = { ...record };
delete next[key];
return next;
}
@@ -3,6 +3,7 @@ import { computed, Injectable, inject, signal } from '@angular/core';
import { finalize } from 'rxjs';
import { FileDownloadService } from './file-download.service';
import { TenantAttachment, TenantService } from './tenant.service';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
@Injectable({ providedIn: 'root' })
export class TenantAttachmentTransferService {
@@ -30,15 +31,15 @@ export class TenantAttachmentTransferService {
upload(tenantId: string, file: File, onUploaded: (attachment: TenantAttachment) => void): void {
if (this.uploadingTenantIds().has(tenantId)) return;
this.errors.update((errors) => this.withoutKey(errors, tenantId));
this.errors.update((errors) => withoutRecordKey(errors, tenantId));
this.uploadingTenantIds.update((ids) => new Set(ids).add(tenantId));
this.uploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
this.tenantService
.uploadAttachment(tenantId, file)
.pipe(
finalize(() => {
this.uploadingTenantIds.update((ids) => this.without(ids, tenantId));
this.uploadProgress.update((progress) => this.withoutKey(progress, tenantId));
this.uploadingTenantIds.update((ids) => withoutSetValue(ids, tenantId));
this.uploadProgress.update((progress) => withoutRecordKey(progress, tenantId));
}),
)
.subscribe({
@@ -78,18 +79,6 @@ export class TenantAttachmentTransferService {
return `attachment:${attachmentId}`;
}
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
const next = new Set(values);
next.delete(value);
return next;
}
private withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
const next = { ...record };
delete next[key];
return next;
}
private errorMessage(error: unknown, fallback: string): string {
return typeof error === 'object' &&
error !== null &&
+16 -23
View File
@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import { finalize, Observable } from 'rxjs';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
export interface LoadState {
loaded: boolean;
@@ -37,21 +38,16 @@ export class WorkspaceResource<T extends { id?: string }> {
this.loadState.set({ loaded: true, loading: false, error: '' });
if (revision !== this.revision) this.load();
},
error: () =>
this.loadState.update((state) => ({
...state,
loading: false,
error: this.messages.load,
})),
error: () => this.handleLoadError(revision),
});
}
save(key: string, request: Observable<T>, onSuccess?: () => void): void {
if (this.savingKeys().has(key)) return;
this.savingKeys.update((keys) => new Set(keys).add(key));
this.saveErrors.update((errors) => this.withoutKey(errors, key));
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(finalize(() => this.savingKeys.update((keys) => this.without(keys, key))))
.pipe(finalize(() => this.savingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
next: (saved) => {
this.upsert(saved);
@@ -65,9 +61,9 @@ export class WorkspaceResource<T extends { id?: string }> {
delete(id: string, request: Observable<void>): void {
if (this.deletingIds().has(id)) return;
this.deletingIds.update((ids) => new Set(ids).add(id));
this.deleteErrors.update((errors) => this.withoutKey(errors, id));
this.deleteErrors.update((errors) => withoutRecordKey(errors, id));
request
.pipe(finalize(() => this.deletingIds.update((ids) => this.without(ids, id))))
.pipe(finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, id))))
.subscribe({
next: () => {
this.confirmChange((items) => items.filter((item) => item.id !== id));
@@ -91,7 +87,16 @@ export class WorkspaceResource<T extends { id?: string }> {
}
clearSaveError(key: string): void {
this.saveErrors.update((errors) => this.withoutKey(errors, key));
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
}
private handleLoadError(revision: number): void {
this.loadState.update((state) => ({
...state,
loading: false,
error: this.messages.load,
}));
if (revision !== this.revision) this.load();
}
private confirmChange(updateItems: (items: T[]) => T[]): void {
@@ -99,16 +104,4 @@ export class WorkspaceResource<T extends { id?: string }> {
this.items.update(updateItems);
this.loadState.update((state) => ({ ...state, loaded: true, error: '' }));
}
private without(values: ReadonlySet<string>, value: string): ReadonlySet<string> {
const next = new Set(values);
next.delete(value);
return next;
}
private withoutKey<V>(record: Record<string, V>, key: string): Record<string, V> {
const next = { ...record };
delete next[key];
return next;
}
}
@@ -160,6 +160,26 @@ describe('WorkspaceService', () => {
expect(apartments.getApartments).toHaveBeenCalledTimes(2);
});
it('reconciles after an older refresh fails behind a confirmed apartment save', () => {
const updatedApartment = { ...apartment, name: 'T2 rénové' };
const staleRefresh = new Subject<(typeof apartment)[]>();
const reconciledRefresh = new Subject<(typeof apartment)[]>();
apartments.getApartments
.mockReturnValueOnce(staleRefresh)
.mockReturnValueOnce(reconciledRefresh);
apartments.updateApartment.mockReturnValueOnce(of(updatedApartment));
const service = TestBed.inject(WorkspaceService);
service.apartments.set([apartment]);
service.loadApartments();
service.saveApartment(updatedApartment, apartment.id);
staleRefresh.error(new Error('offline'));
expect(service.apartments()).toEqual([updatedApartment]);
expect(apartments.getApartments).toHaveBeenCalledTimes(2);
expect(service.apartmentLoadState()).toEqual({ loaded: true, loading: true, error: '' });
});
it('scopes concurrent apartment saves and failures to their records', () => {
const firstSave = new Subject<typeof apartment>();
const secondApartment = { ...apartment, id: 'apartment-2', name: 'T3' };