fix: complete loading review coverage (#18)
This commit is contained in:
1 parent
0f68a91c5d
commit
a8a3d7a747
8 files changed
+107
-52
No files matched your search
@@ -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 &&
|
||||
|
||||
@@ -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' };
|
||||
|
||||
Reference in new issue
Block a user