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(() => {
|
beforeEach(() => {
|
||||||
status.set('checking');
|
status.set('checking');
|
||||||
session.isLoggedIn.set(false);
|
session.isLoggedIn.set(false);
|
||||||
|
session.username.set('');
|
||||||
|
session.email.set('');
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
|
providers: [provideRouter([]), { provide: SessionService, useValue: session }],
|
||||||
@@ -41,4 +43,24 @@ describe('App', () => {
|
|||||||
expect(session.refresh).toHaveBeenCalled();
|
expect(session.refresh).toHaveBeenCalled();
|
||||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
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.',
|
'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é');
|
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', () => {
|
it('keeps established apartments visible during a background refresh', () => {
|
||||||
workspace.apartments.set([
|
workspace.apartments.set([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { withoutRecordKey, withoutSetValue } from './immutable-state';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class FileDownloadService {
|
export class FileDownloadService {
|
||||||
@@ -17,9 +18,9 @@ export class FileDownloadService {
|
|||||||
download(key: string, request: Observable<Blob>, fileName: string, errorMessage: string): void {
|
download(key: string, request: Observable<Blob>, fileName: string, errorMessage: string): void {
|
||||||
if (this.pendingKeys().has(key)) return;
|
if (this.pendingKeys().has(key)) return;
|
||||||
this.pendingKeys.update((keys) => new Set(keys).add(key));
|
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
|
request
|
||||||
.pipe(finalize(() => this.pendingKeys.update((keys) => this.without(keys, key))))
|
.pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (blob) => this.save(blob, fileName),
|
next: (blob) => this.save(blob, fileName),
|
||||||
error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })),
|
error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })),
|
||||||
@@ -34,16 +35,4 @@ export class FileDownloadService {
|
|||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
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 { finalize } from 'rxjs';
|
||||||
import { FileDownloadService } from './file-download.service';
|
import { FileDownloadService } from './file-download.service';
|
||||||
import { TenantAttachment, TenantService } from './tenant.service';
|
import { TenantAttachment, TenantService } from './tenant.service';
|
||||||
|
import { withoutRecordKey, withoutSetValue } from './immutable-state';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TenantAttachmentTransferService {
|
export class TenantAttachmentTransferService {
|
||||||
@@ -30,15 +31,15 @@ export class TenantAttachmentTransferService {
|
|||||||
|
|
||||||
upload(tenantId: string, file: File, onUploaded: (attachment: TenantAttachment) => void): void {
|
upload(tenantId: string, file: File, onUploaded: (attachment: TenantAttachment) => void): void {
|
||||||
if (this.uploadingTenantIds().has(tenantId)) return;
|
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.uploadingTenantIds.update((ids) => new Set(ids).add(tenantId));
|
||||||
this.uploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
|
this.uploadProgress.update((progress) => ({ ...progress, [tenantId]: null }));
|
||||||
this.tenantService
|
this.tenantService
|
||||||
.uploadAttachment(tenantId, file)
|
.uploadAttachment(tenantId, file)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.uploadingTenantIds.update((ids) => this.without(ids, tenantId));
|
this.uploadingTenantIds.update((ids) => withoutSetValue(ids, tenantId));
|
||||||
this.uploadProgress.update((progress) => this.withoutKey(progress, tenantId));
|
this.uploadProgress.update((progress) => withoutRecordKey(progress, tenantId));
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -78,18 +79,6 @@ export class TenantAttachmentTransferService {
|
|||||||
return `attachment:${attachmentId}`;
|
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 {
|
private errorMessage(error: unknown, fallback: string): string {
|
||||||
return typeof error === 'object' &&
|
return typeof error === 'object' &&
|
||||||
error !== null &&
|
error !== null &&
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { withoutRecordKey, withoutSetValue } from './immutable-state';
|
||||||
|
|
||||||
export interface LoadState {
|
export interface LoadState {
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@@ -37,21 +38,16 @@ export class WorkspaceResource<T extends { id?: string }> {
|
|||||||
this.loadState.set({ loaded: true, loading: false, error: '' });
|
this.loadState.set({ loaded: true, loading: false, error: '' });
|
||||||
if (revision !== this.revision) this.load();
|
if (revision !== this.revision) this.load();
|
||||||
},
|
},
|
||||||
error: () =>
|
error: () => this.handleLoadError(revision),
|
||||||
this.loadState.update((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: this.messages.load,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
save(key: string, request: Observable<T>, onSuccess?: () => void): void {
|
save(key: string, request: Observable<T>, onSuccess?: () => void): void {
|
||||||
if (this.savingKeys().has(key)) return;
|
if (this.savingKeys().has(key)) return;
|
||||||
this.savingKeys.update((keys) => new Set(keys).add(key));
|
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
|
request
|
||||||
.pipe(finalize(() => this.savingKeys.update((keys) => this.without(keys, key))))
|
.pipe(finalize(() => this.savingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (saved) => {
|
next: (saved) => {
|
||||||
this.upsert(saved);
|
this.upsert(saved);
|
||||||
@@ -65,9 +61,9 @@ export class WorkspaceResource<T extends { id?: string }> {
|
|||||||
delete(id: string, request: Observable<void>): void {
|
delete(id: string, request: Observable<void>): void {
|
||||||
if (this.deletingIds().has(id)) return;
|
if (this.deletingIds().has(id)) return;
|
||||||
this.deletingIds.update((ids) => new Set(ids).add(id));
|
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
|
request
|
||||||
.pipe(finalize(() => this.deletingIds.update((ids) => this.without(ids, id))))
|
.pipe(finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, id))))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.confirmChange((items) => items.filter((item) => item.id !== id));
|
this.confirmChange((items) => items.filter((item) => item.id !== id));
|
||||||
@@ -91,7 +87,16 @@ export class WorkspaceResource<T extends { id?: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSaveError(key: string): void {
|
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 {
|
private confirmChange(updateItems: (items: T[]) => T[]): void {
|
||||||
@@ -99,16 +104,4 @@ export class WorkspaceResource<T extends { id?: string }> {
|
|||||||
this.items.update(updateItems);
|
this.items.update(updateItems);
|
||||||
this.loadState.update((state) => ({ ...state, loaded: true, error: '' }));
|
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);
|
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', () => {
|
it('scopes concurrent apartment saves and failures to their records', () => {
|
||||||
const firstSave = new Subject<typeof apartment>();
|
const firstSave = new Subject<typeof apartment>();
|
||||||
const secondApartment = { ...apartment, id: 'apartment-2', name: 'T3' };
|
const secondApartment = { ...apartment, id: 'apartment-2', name: 'T3' };
|
||||||
|
|||||||
Reference in new issue
Block a user