feat(fp): WP-09 — pure-logic closure: dates + missing command specs
Consolidate four hand-rolled nl-NL date formatters (tasks.ts, aanvraag- block, letter-preview, aanvraag-view -- one more than the WP found) into one shared/kernel/datum.ts::formatDatumNl, spec-pinned and empty-safe. Add the two missing command specs CLAUDE.md's testing rule calls for: draft-sync.spec.ts (debounce coalescing + trailing-call + submit Result shape, via fake timers) and submit-change-request.spec.ts. Remove the unused RemoteData.map3 (updating the three docs that mentioned it); the variant input on confirmation.component.ts was already gone. Documents both stale-WP-text corrections in the backlog file. This closes out backlog Phase 1 (FP/DDD core, WP-05..09).
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { ApplicationRef, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { createDraftSync, DraftSnapshot } from './draft-sync';
|
||||
|
||||
function setup(adapter: Partial<ApplicationsAdapter>) {
|
||||
const navigate = vi.fn().mockResolvedValue(true);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ApplicationsAdapter, useValue: adapter },
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
|
||||
],
|
||||
});
|
||||
const snap = signal<DraftSnapshot | null>(null);
|
||||
const onResume = vi.fn();
|
||||
const draftSync = TestBed.runInInjectionContext(() =>
|
||||
createDraftSync({
|
||||
type: 'registratie',
|
||||
snapshot: () => snap(),
|
||||
onResume,
|
||||
enabled: () => true,
|
||||
}),
|
||||
);
|
||||
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
|
||||
return { draftSync, snap, navigate, onResume };
|
||||
}
|
||||
|
||||
const tick = () => TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
describe('createDraftSync', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { snap } = setup({ create, syncDraft });
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
|
||||
// still inside the 600ms debounce window — nothing has synced yet
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(syncDraft).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
|
||||
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
|
||||
expect(syncDraft).toHaveBeenCalledWith(
|
||||
'a1',
|
||||
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
|
||||
);
|
||||
});
|
||||
|
||||
it('a trailing change after the debounce fires schedules its own sync', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||
const { snap } = setup({ create, syncDraft });
|
||||
|
||||
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(syncDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
|
||||
tick();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(syncDraft).toHaveBeenCalledTimes(2);
|
||||
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('resolves ok with the server response on success', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
|
||||
const { draftSync } = setup({ create, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
|
||||
expect(submit).toHaveBeenCalledWith('a1', {});
|
||||
});
|
||||
|
||||
it('folds a rejected submit into a Result error, never throwing', async () => {
|
||||
const create = vi.fn().mockResolvedValue('a1');
|
||||
const submit = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const { draftSync } = setup({ create, submit });
|
||||
|
||||
const r = await draftSync.submit({});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Valid } from '@registratie/domain/change-request.machine';
|
||||
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
|
||||
import { createSubmitChangeRequest } from './submit-change-request';
|
||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
const postcode = parsePostcode('2514 EA');
|
||||
if (!postcode.ok) throw new Error('fixture postcode should parse');
|
||||
|
||||
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
|
||||
|
||||
function setup(adapter: Partial<ChangeRequestAdapter>) {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
|
||||
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
||||
}
|
||||
|
||||
describe('createSubmitChangeRequest', () => {
|
||||
it('resolves ok with the referentie on success', async () => {
|
||||
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
|
||||
const r = await submit(data);
|
||||
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
|
||||
});
|
||||
|
||||
it('folds a rejected call into a Result error, never throwing', async () => {
|
||||
const submit = setup({
|
||||
changeRequest: () => Promise.reject(new Error('network kaput')),
|
||||
});
|
||||
const r = await submit(data);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
|
||||
const submit = setup({
|
||||
changeRequest: () => Promise.reject({ detail: 'Postcode komt niet overeen met de straat.' }),
|
||||
});
|
||||
const r = await submit(data);
|
||||
expect(r).toEqual({ ok: false, error: 'Postcode komt niet overeen met de straat.' });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
|
||||
|
||||
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
|
||||
@@ -49,12 +50,6 @@ export interface AanvraagRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function formatNL(iso?: string): string {
|
||||
return iso
|
||||
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
|
||||
has no row — it renders as a resumable melding, see aanvraag-block). */
|
||||
export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||
@@ -63,7 +58,9 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||
const ref = referentie(s);
|
||||
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
|
||||
if (a.submittedAt)
|
||||
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
|
||||
parts.push(
|
||||
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`,
|
||||
);
|
||||
if (s.tag === 'InBehandeling' && s.manual)
|
||||
parts.push(
|
||||
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
||||
@@ -88,7 +85,7 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
|
||||
},
|
||||
{
|
||||
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
|
||||
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
|
||||
value: a.submittedAt ? formatDatumNl(a.submittedAt) : '—',
|
||||
},
|
||||
];
|
||||
if (a.status.tag === 'Afgewezen') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Registration } from './registration';
|
||||
import { herregistratieDeadline } from './registration.policy';
|
||||
|
||||
@@ -12,10 +13,6 @@ export interface PortalTask {
|
||||
actionLabel: string;
|
||||
}
|
||||
|
||||
function formatNL(d: Date): string {
|
||||
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the open tasks for a professional (pure). Eligibility is the server's
|
||||
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
|
||||
@@ -33,7 +30,7 @@ export function tasksFromProfile(
|
||||
tasks.push({
|
||||
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
|
||||
description: deadline
|
||||
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.`
|
||||
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatDatumNl(deadline)}:deadline:.`
|
||||
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
|
||||
to: '/herregistratie',
|
||||
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
@@ -65,12 +66,6 @@ export class AanvraagBlockComponent {
|
||||
protected conceptText = computed(() => {
|
||||
const s = this.aanvraag().status;
|
||||
if (s.tag !== 'Concept') return '';
|
||||
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`;
|
||||
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatDatumNl(this.deadline())}:datum:.`;
|
||||
});
|
||||
}
|
||||
|
||||
function formatNL(iso?: string): string {
|
||||
return iso
|
||||
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user