560 lines
18 KiB
TypeScript
560 lines
18 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
||
import { By } from '@angular/platform-browser';
|
||
import { vi } from 'vitest';
|
||
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
|
||
import { ApartmentForm } from './apartment-form';
|
||
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
|
||
|
||
describe('ApartmentForm', () => {
|
||
const completeLegalProfile = {
|
||
taxIdentifier: '751150001234',
|
||
buildingType: 'APARTMENT_BUILDING' as const,
|
||
ownershipRegime: 'Copropriété',
|
||
constructionPeriod: '1990_2005' as const,
|
||
habitableSurface: 42,
|
||
equipment: 'Cuisine équipée',
|
||
heating: 'Individuel électrique',
|
||
hotWater: 'Ballon électrique',
|
||
energyRating: 'D' as const,
|
||
estimatedAnnualEnergyCost: 850,
|
||
estimatedAnnualEnergyCostReferenceYear: 2021,
|
||
destination: 'Habitation',
|
||
privateAccessories: 'Cave',
|
||
commonAreas: 'Local vélos',
|
||
internetAccess: 'Fibre',
|
||
departmentCode: '75',
|
||
};
|
||
|
||
it('initializes its fields from the apartment being edited', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('apartment', {
|
||
id: 'apt-1',
|
||
name: 'T2 Paris 15',
|
||
address: '45 rue des Entrepreneurs, 75015 Paris',
|
||
purchasePrice: 250000,
|
||
description: 'Cinquième étage',
|
||
privateAreas: [],
|
||
legalProfile: completeLegalProfile,
|
||
});
|
||
fixture.detectChanges();
|
||
|
||
const form = fixture.componentInstance;
|
||
expect(form.apartmentModel().name).toBe('T2 Paris 15');
|
||
expect(form.apartmentModel().purchasePrice).toBe(250000);
|
||
expect(form.apartmentModel().legalProfile).toEqual(completeLegalProfile);
|
||
});
|
||
|
||
it('shows a validation error instead of emitting an incomplete apartment', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
|
||
form.save();
|
||
|
||
expect(form.error()).toContain('Le nom de l’appartement est obligatoire.');
|
||
expect(saved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('shows every invalid section and focuses the first control in form order', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: ' ',
|
||
address: ' ',
|
||
purchasePrice: 0,
|
||
description: '',
|
||
privateAreas: [
|
||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: ' ' },
|
||
],
|
||
legalProfile: {
|
||
...emptyApartmentLegalProfile(),
|
||
habitableSurface: -1,
|
||
departmentCode: '20',
|
||
},
|
||
});
|
||
|
||
form.save();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
const text = fixture.nativeElement.textContent;
|
||
expect(saved).not.toHaveBeenCalled();
|
||
expect(text).toContain('Le nom de l’appartement est obligatoire.');
|
||
expect(text).toContain('Le prix d’acquisition doit être strictement positif.');
|
||
expect(text).toContain('L’adresse complète est obligatoire.');
|
||
expect(text).toContain('Saisissez un département français');
|
||
expect(text).toContain('La surface habitable doit être positive ou nulle.');
|
||
expect(text).toContain('Le libellé est obligatoire.');
|
||
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#name'));
|
||
});
|
||
|
||
it('clears its client summary when a field is corrected', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const form = fixture.componentInstance;
|
||
|
||
form.save();
|
||
fixture.nativeElement.querySelector('#name').dispatchEvent(new Event('input'));
|
||
|
||
expect(form.error()).toBe('');
|
||
});
|
||
|
||
it('focuses the first mapped server error after a failed save', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
fixture.componentRef.setInput('saveFieldErrors', [
|
||
{ field: 'address', code: 'not_blank' },
|
||
{ field: 'legalProfile.departmentCodeValid', code: 'assert_true' },
|
||
{ field: 'privateAreas[0].label', code: 'not_blank' },
|
||
]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#address'));
|
||
});
|
||
|
||
it('rejects whitespace-only fields before the backend does', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: ' ',
|
||
address: ' ',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: emptyApartmentLegalProfile(),
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
|
||
expect(form.addressError()).toBe('L’adresse complète est obligatoire.');
|
||
expect(saved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('accepts every strictly positive purchase price, including a sub-euro value', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 0.5,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: emptyApartmentLegalProfile(),
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ purchasePrice: 0.5 }));
|
||
});
|
||
|
||
it('keeps invalid private-area cards visible, shows their inline error, and focuses the label', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'Studio',
|
||
address: '1 rue du Test',
|
||
purchasePrice: 1,
|
||
description: '',
|
||
privateAreas: [
|
||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: ' ' },
|
||
],
|
||
legalProfile: emptyApartmentLegalProfile(),
|
||
});
|
||
fixture.detectChanges();
|
||
|
||
form.save();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(saved).not.toHaveBeenCalled();
|
||
expect(form.error()).toContain('Renseignez le libellé de chaque espace privatif.');
|
||
expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength(1);
|
||
expect(fixture.nativeElement.textContent).toContain('Le libellé est obligatoire.');
|
||
expect(document.activeElement).toBe(
|
||
fixture.nativeElement.querySelector('.private-area-label'),
|
||
);
|
||
});
|
||
|
||
it('forwards only the edited private-area server error path', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saveFieldErrors', [
|
||
{ field: 'privateAreas[0].label', code: 'not_blank' },
|
||
{ field: 'privateAreas[0].subtypeCompatibleWithFamily', code: 'assert_true' },
|
||
]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const changed = vi.fn();
|
||
fixture.componentInstance.fieldChanged.subscribe(changed);
|
||
fixture.componentInstance.apartmentModel.set({
|
||
name: 'Studio',
|
||
address: '1 rue du Test',
|
||
purchasePrice: 1,
|
||
description: '',
|
||
privateAreas: [
|
||
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: 'Séjour' },
|
||
],
|
||
legalProfile: emptyApartmentLegalProfile(),
|
||
});
|
||
fixture.detectChanges();
|
||
|
||
fixture.nativeElement
|
||
.querySelector('.private-area-label')
|
||
.dispatchEvent(new Event('input'));
|
||
|
||
expect(changed).toHaveBeenCalledTimes(1);
|
||
expect(changed).toHaveBeenCalledWith('privateAreas[0].label');
|
||
});
|
||
|
||
it('shows backend core errors beside their controls and clears one when it is edited', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const changed = vi.fn();
|
||
form.fieldChanged.subscribe(changed);
|
||
fixture.componentRef.setInput('saveFieldErrors', [
|
||
{ field: 'name', code: 'not_blank' },
|
||
{ field: 'address', code: 'not_blank' },
|
||
]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
|
||
fixture.detectChanges();
|
||
expect(fixture.nativeElement.querySelectorAll('.field-error')[0].textContent).toContain(
|
||
'Le nom de l’appartement est obligatoire.',
|
||
);
|
||
expect(fixture.nativeElement.querySelectorAll('.field-error')[1].textContent).toContain(
|
||
'L’adresse complète est obligatoire.',
|
||
);
|
||
|
||
fixture.nativeElement.querySelector('input#name').dispatchEvent(new Event('input'));
|
||
|
||
expect(changed).toHaveBeenCalledWith('name');
|
||
});
|
||
|
||
it('keeps unknown server validation codes in the generic save alert', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
|
||
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'name', code: 'unknown' }]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('.field-error')).toBeNull();
|
||
expect(fixture.nativeElement.querySelector('.alert-error').textContent).toContain(
|
||
'Veuillez corriger les champs signalés.',
|
||
);
|
||
});
|
||
|
||
it('keeps malformed private-area server paths in the generic alert', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
|
||
fixture.componentRef.setInput('saveFieldErrors', [
|
||
{ field: 'privateAreas[].label', code: 'not_blank' },
|
||
{ field: 'privateAreas[-1].label', code: 'not_blank' },
|
||
]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(fixture.nativeElement.querySelector('.alert-error').textContent).toContain(
|
||
'Veuillez corriger les champs signalés.',
|
||
);
|
||
});
|
||
|
||
it('keeps only unknown server validation failures in the generic alert', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
|
||
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'name', code: 'not_blank' }]);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(fixture.nativeElement.querySelector('.alert-error')).toBeNull();
|
||
expect(fixture.nativeElement.textContent).toContain(
|
||
'Le nom de l’appartement est obligatoire.',
|
||
);
|
||
});
|
||
|
||
it('emits a trimmed apartment draft when valid', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: ' T2 Paris 15 ',
|
||
address: ' 45 rue des Entrepreneurs ',
|
||
purchasePrice: 250000,
|
||
description: ' Cinquième étage ',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...completeLegalProfile,
|
||
taxIdentifier: ' 751150001234 ',
|
||
equipment: ' Cuisine équipée ',
|
||
departmentCode: ' 2a ',
|
||
},
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(saved).toHaveBeenCalledWith({
|
||
id: undefined,
|
||
name: 'T2 Paris 15',
|
||
address: '45 rue des Entrepreneurs',
|
||
purchasePrice: 250000,
|
||
description: 'Cinquième étage',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...completeLegalProfile,
|
||
taxIdentifier: '751150001234',
|
||
equipment: 'Cuisine équipée',
|
||
departmentCode: '2A',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('disables the whole form and preserves a save error while pending', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saving', true);
|
||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
|
||
expect(fixture.nativeElement.querySelector('[role=status]').closest('fieldset')).toBeNull();
|
||
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
|
||
expect(fixture.nativeElement.textContent).toContain(
|
||
'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.apartmentModel.set({
|
||
name: 'T2 rénové',
|
||
address: '45 rue des Entrepreneurs',
|
||
purchasePrice: 265000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: emptyApartmentLegalProfile(),
|
||
});
|
||
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(fixture.nativeElement.querySelector('input#name').value).toBe('T2 rénové');
|
||
expect(fixture.nativeElement.querySelector('input#address').value).toBe(
|
||
'45 rue des Entrepreneurs',
|
||
);
|
||
expect(fixture.nativeElement.textContent).toContain(
|
||
'Impossible d’enregistrer cet appartement.',
|
||
);
|
||
});
|
||
|
||
it('rejects negative legal-profile numbers', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: { ...emptyApartmentLegalProfile(), habitableSurface: -1 },
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(form.error()).toContain('surface habitable');
|
||
expect(saved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('accepts zero legal-profile numbers with the first valid reference year', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...emptyApartmentLegalProfile(),
|
||
habitableSurface: 0,
|
||
estimatedAnnualEnergyCost: 0,
|
||
estimatedAnnualEnergyCostReferenceYear: 1900,
|
||
},
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(saved).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
legalProfile: expect.objectContaining({
|
||
habitableSurface: 0,
|
||
estimatedAnnualEnergyCost: 0,
|
||
estimatedAnnualEnergyCostReferenceYear: 1900,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('rejects an out-of-range energy reference year when an estimated cost is provided', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...emptyApartmentLegalProfile(),
|
||
estimatedAnnualEnergyCost: 850,
|
||
estimatedAnnualEnergyCostReferenceYear: 1800,
|
||
},
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(form.error()).toContain('1900 et 2100');
|
||
expect(saved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects an invalid department code inline', () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
const form = fixture.componentInstance;
|
||
const saved = vi.fn();
|
||
form.saved.subscribe(saved);
|
||
form.apartmentModel.set({
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: { ...emptyApartmentLegalProfile(), departmentCode: '20' },
|
||
});
|
||
|
||
form.save();
|
||
|
||
expect(form.error()).toContain('département français');
|
||
expect(saved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('shows known legal-profile server errors next to their controls', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('saveFieldErrors', [
|
||
{ field: 'legalProfile.departmentCodeValid', code: 'assert_true' },
|
||
]);
|
||
const changed = vi.fn();
|
||
fixture.componentInstance.fieldChanged.subscribe(changed);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(
|
||
fixture.debugElement
|
||
.query(By.directive(ApartmentLegalProfileForm))
|
||
.componentInstance.departmentCodeError(),
|
||
).toContain('Saisissez un département français');
|
||
fixture.nativeElement.querySelector('#departmentCode').dispatchEvent(new Event('input'));
|
||
expect(changed).toHaveBeenCalledWith('legalProfile.departmentCodeValid');
|
||
});
|
||
|
||
it('turns an emptied nullable number field back into null', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const surfaceInput: HTMLInputElement =
|
||
fixture.nativeElement.querySelector('input#habitableSurface');
|
||
|
||
surfaceInput.value = '42';
|
||
surfaceInput.dispatchEvent(new Event('input'));
|
||
surfaceInput.value = '';
|
||
surfaceInput.dispatchEvent(new Event('input'));
|
||
await fixture.whenStable();
|
||
|
||
expect(fixture.componentInstance.apartmentModel().legalProfile.habitableSurface).toBeNull();
|
||
});
|
||
|
||
it('asks for the energy reference year when an estimated cost is provided', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('apartment', {
|
||
id: 'apartment-1',
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...emptyApartmentLegalProfile(),
|
||
estimatedAnnualEnergyCost: 850,
|
||
},
|
||
});
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const saved = vi.fn();
|
||
fixture.componentInstance.saved.subscribe(saved);
|
||
|
||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||
fixture.detectChanges();
|
||
|
||
expect(saved).not.toHaveBeenCalled();
|
||
expect(fixture.nativeElement.textContent).toContain(
|
||
"Renseignez l'année de référence du coût énergétique.",
|
||
);
|
||
});
|
||
|
||
it('clears a stale energy reference year that has no estimated cost', async () => {
|
||
const fixture = TestBed.createComponent(ApartmentForm);
|
||
fixture.componentRef.setInput('apartment', {
|
||
id: 'apartment-1',
|
||
name: 'T2',
|
||
address: '1 rue de Paris',
|
||
purchasePrice: 250000,
|
||
description: '',
|
||
privateAreas: [],
|
||
legalProfile: {
|
||
...emptyApartmentLegalProfile(),
|
||
estimatedAnnualEnergyCostReferenceYear: 1800,
|
||
},
|
||
});
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const saved = vi.fn();
|
||
fixture.componentInstance.saved.subscribe(saved);
|
||
|
||
fixture.nativeElement.querySelector('button[type=submit]').click();
|
||
|
||
expect(saved).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
legalProfile: expect.objectContaining({
|
||
estimatedAnnualEnergyCostReferenceYear: null,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
});
|