import { TestBed } from '@angular/core/testing'; import { HttpErrorResponse } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { SchoolApi } from '../core/school-api'; import { EntityEditor } from './entity-editor'; import { EditorTarget, Show } from '../core/api-models'; describe('EntityEditor', () => { const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() }; beforeEach(() => { vi.resetAllMocks(); TestBed.configureTestingModule({ imports: [EntityEditor], providers: [{ provide: SchoolApi, useValue: api }], }); }); function editor(target: EditorTarget, shows: Show[] = []) { const fixture = TestBed.createComponent(EntityEditor); fixture.componentRef.setInput('target', target); fixture.componentRef.setInput('schoolId', 7); fixture.componentRef.setInput('shows', shows); fixture.componentInstance.ngOnInit(); return fixture.componentInstance; } it('rejects blank names and invalid age ranges before calling the API', async () => { const component = editor({ kind: 'group' }); component.name = ' '; expect(component.validDetails()).toBe(false); component.name = 'Teens'; component.startAge = 18; component.endAge = 12; await component.save(); expect(api.saveGroup).not.toHaveBeenCalled(); component.endAge = 18.5; expect(component.validDetails()).toBe(false); }); it('does not accept a future birth date', () => { const component = editor({ kind: 'dancer' }); component.name = 'Sam'; component.date = '2999-01-01'; expect(component.validDetails()).toBe(false); }); it('trims the school name and emits saved only after success', async () => { api.saveSchool.mockReturnValue(of({ id: 0 })); const component = editor({ kind: 'school' }); component.name = ' Studio '; const saved = vi.fn(); component.saved.subscribe(saved); await component.save(); expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined, undefined); expect(saved).toHaveBeenCalledOnce(); expect(component.busy()).toBe(false); }); it('retains the revision from opening the editor across background refreshes', async () => { api.saveDancer.mockReturnValue(of({ id: 3 })); const fixture = TestBed.createComponent(EntityEditor); fixture.componentRef.setInput('target', { kind: 'dancer', value: { id: 3, name: 'Sam', birthDate: '2010-01-01' }, }); fixture.componentRef.setInput('schoolId', 7); fixture.componentRef.setInput('revision', 4); const component = fixture.componentInstance; component.ngOnInit(); fixture.componentRef.setInput('revision', 5); component.name = 'Sam edited'; await component.save(); expect(api.saveDancer).toHaveBeenCalledWith( 7, { name: 'Sam edited', birthDate: '2010-01-01' }, 3, 4, ); }); it('keeps the editor open and preserves input when saving fails', async () => { api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); const component = editor({ kind: 'school' }); component.name = 'Studio'; const saved = vi.fn(); component.saved.subscribe(saved); await component.save(); expect(saved).not.toHaveBeenCalled(); expect(component.name).toBe('Studio'); expect(component.error()).toContain('niet bereikbaar'); expect(component.busy()).toBe(false); }); const show: Show = { id: 3, name: 'Summer', preferredDayParts: 2, finalized: false, violations: [], dayParts: [ { date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] }, { date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] }, ], appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 }, }; it('defaults preferredDayParts to two and rejects duplicate dated dayparts and fractional preferredDayParts', () => { const component = editor({ kind: 'show' }); component.name = 'Show'; expect(component.validDetails()).toBe(true); component.addPart(); component.dayParts[0].date = '2027-06-01'; component.addPart(); component.dayParts[1].date = '2027-06-02'; component.addPart(); component.dayParts[2].date = '2027-06-03'; expect(component.preferredDayParts).toBe(2); expect(component.validDetails()).toBe(true); component.dayParts[2].date = '2027-06-02'; expect(component.validDetails()).toBe(false); component.dayParts[2].date = '2027-06-03'; component.preferredDayParts = 1.5; expect(component.validDetails()).toBe(false); }); it('preserves an explicitly chosen preferredDayParts when adding dayparts to a new show', async () => { api.saveShow.mockReturnValue(of(show)); const component = editor({ kind: 'show' }); component.name = 'Show'; component.addPart(); component.dayParts[0].date = '2027-06-01'; component.addPart(); component.dayParts[1].date = '2027-06-02'; component.preferredDayParts = 1; component.addPart(); component.dayParts[2].date = '2027-06-03'; expect(component.preferredDayParts).toBe(1); await component.save(); expect(api.saveShow).toHaveBeenCalledWith( 7, expect.objectContaining({ preferredDayParts: 1 }), undefined, undefined, ); }); it('retains preferredDayParts when dayparts are removed so it can be explicitly corrected', () => { const component = editor({ kind: 'show', value: show }); component.addPart(); component.dayParts[2].date = '2027-06-03'; expect(component.preferredDayParts).toBe(2); component.removePart(2); component.removePart(1); expect(component.preferredDayParts).toBe(2); expect(component.validDetails()).toBe(true); component.preferredDayParts = 1; expect(component.validDetails()).toBe(true); }); it('allows cancelling an invalidating edit and skips warnings for name-only edits', async () => { api.saveShow.mockReturnValue(of(show)); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); try { const component = editor({ kind: 'show', value: show }); component.preferredDayParts = 1; await component.save(); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); expect(api.saveShow).not.toHaveBeenCalled(); component.preferredDayParts = 2; confirm.mockClear(); component.name = 'Renamed'; await component.save(); expect(confirm).not.toHaveBeenCalled(); expect(api.saveShow).toHaveBeenCalledOnce(); } finally { confirm.mockRestore(); } }); it('names affected shows before adding a group and allows cancellation', async () => { const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); try { const component = editor({ kind: 'group' }, [show]); component.name = 'New group'; await component.save(); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); expect(api.saveGroup).not.toHaveBeenCalled(); } finally { confirm.mockRestore(); } }); it('displays one editable preferredDayParts field with a default of one for a one-daypart show', async () => { // jsdom does not implement native dialog opening. const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { configurable: true, value: () => undefined, }); try { const fixture = TestBed.createComponent(EntityEditor); fixture.componentRef.setInput('target', { kind: 'show' }); fixture.componentInstance.addPart(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; const inputs = Array.from(element.querySelectorAll('input[type="number"]')); expect(inputs).toHaveLength(1); expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true); } finally { if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor); else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal'); } }); });