-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathdashboard-edit-multishift.spec.ts
More file actions
412 lines (369 loc) · 19.7 KB
/
dashboard-edit-multishift.spec.ts
File metadata and controls
412 lines (369 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
import { test, expect, Page } from '@playwright/test';
import { LoginPage } from '../../../Page objects/Login.page';
/**
* Regression guard for the multi-shift (3-5) save + render pipeline.
*
* Prior bug: the C# `Update()` method only copied shift 1-2 from the request
* model onto the PlanRegistration entity — shifts 3-5 were silently dropped.
* A round-trip that fills all 5 shifts in the workday-entity dialog and
* re-reads them from the table cell + dialog is the minimum guard against
* that regression ever coming back.
*
* Shift layout used by this test:
* Shift 1: 00:00-01:00 break 00:05
* Shift 2: 02:00-03:00 break 00:10
* Shift 3: 04:00-05:00 break 00:15
* Shift 4: 06:00-07:00 break 00:20
* Shift 5: 07:00-08:00 break 00:25
*/
async function waitForSpinner(page: Page) {
if (await page.locator('.overlay-spinner').count() > 0) {
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
}
}
async function pickTime(page: Page, timeStr: string) {
// Position-based clock-face clicks (same approach as time-planning-settings.spec.ts).
// Works uniformly for h=0 (break times), unlike rotateZ-selector strategies.
const [hourStr, minuteStr] = timeStr.split(':');
const h = parseInt(hourStr, 10);
const m = parseInt(minuteStr, 10);
const cx = 145, cy = 145;
const hourFace = page.locator('.clock-face');
await hourFace.first().waitFor({ state: 'visible', timeout: 5000 });
const hourAngle = (h % 12) * 30;
const hourR = (h === 0 || h > 12) ? 60 : 100;
const hourRad = hourAngle * Math.PI / 180;
await hourFace.first().click({
position: {
x: Math.round(cx + hourR * Math.sin(hourRad)),
y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0),
},
});
await page.waitForTimeout(500);
const minuteFace = page.locator('.clock-face');
await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 });
const minuteAngle = m * 6;
const minuteR = 100;
const minuteRad = minuteAngle * Math.PI / 180;
await minuteFace.first().click({
position: {
x: Math.round(cx + minuteR * Math.sin(minuteRad)),
y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0),
},
});
await page.waitForTimeout(500);
await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click();
}
async function setShift(page: Page, shiftId: 1|2|3|4|5, start: string, end: string, breakStr: string) {
await page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`).click();
await pickTime(page, start);
await expect(page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`)).toHaveValue(start);
await page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`).click();
await pickTime(page, end);
await expect(page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`)).toHaveValue(end);
await page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`).click();
await pickTime(page, breakStr);
await expect(page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`)).toHaveValue(breakStr);
}
// Times chosen to avoid hour==0 (the Material timepicker's "12" selector
// sits at a non-rotateZ position that breaks the degree-math helper above).
const allFiveShifts = [
{ id: 1 as const, start: '01:00', end: '02:00', break: '00:05' },
{ id: 2 as const, start: '03:00', end: '04:00', break: '00:10' },
{ id: 3 as const, start: '05:00', end: '06:00', break: '00:15' },
{ id: 4 as const, start: '07:00', end: '08:00', break: '00:20' },
{ id: 5 as const, start: '09:00', end: '10:00', break: '00:25' },
];
test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:4200');
await new LoginPage(page).login();
});
test('persists all 5 planned shifts through save + reload', async ({ page }) => {
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
const indexPromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
await indexPromise;
await waitForSpinner(page);
// Shifts 3-5 are only rendered in the workday-entity dialog when the
// assigned site has thirdShiftActive / fourthShiftActive / fifthShiftActive
// flipped on (see workday-entity-dialog.component.ts:354-363). CI seed
// defaults them all to false. The assigned-site dialog also gates the
// 4th/5th checkboxes behind `data.thirdShiftActive` / `data.fourthShiftActive`
// — those bindings reflect the snapshot passed into the dialog, so each
// new checkbox only materialises after a save + reopen cycle.
for (const id of ['thirdShiftActive', 'fourthShiftActive', 'fifthShiftActive']) {
// Wait for the GET that hydrates the dialog model BEFORE the dialog
// even opens. `onFirstColumnClick` fires getAssignedSite() and only
// then calls dialog.open(...), so this response gates whether the
// *ngIf-gated checkbox (fourthShiftActive needs data.thirdShiftActive,
// fifthShiftActive needs data.fourthShiftActive) is ever rendered.
// Previously the test asserted on `mat-dialog-container` visible and
// then hard-waited 10s for the gated input to attach; on slow CI the
// dialog appeared before the GET committed and the *ngIf evaluated
// false, blowing the wait. Awaiting the GET here is the deterministic
// gate.
const getAssignedSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-sites')
&& r.url().includes('siteId=')
&& r.request().method() === 'GET',
{ timeout: 30000 });
await page.locator('#firstColumn3').click();
await getAssignedSitePromise;
await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 30000 });
const cb = page.locator(`#${id} input[type="checkbox"]`);
// expect.toBeAttached() retries continuously and emits a richer error
// log than locator.waitFor() — same observable contract, better flake
// diagnostics.
await expect(cb).toBeAttached({ timeout: 30000 });
if (!(await cb.isChecked())) {
await page.locator(`#${id}`).click({ force: true });
}
await expect(cb).toBeChecked({ timeout: 10000 });
const assignSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT',
{ timeout: 30000 });
await page.locator('#saveButton').click({ force: true });
await assignSitePromise;
await waitForSpinner(page);
await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 15000 });
}
// Day cell id is `cell{rowIndex}_{colField}` — row 3 matches the worker
// whose assigned-site row (#firstColumn3) we just configured above.
const cellId = '#cell3_0';
await page.locator(cellId).scrollIntoViewIfNeeded();
await page.locator(cellId).click();
await expect(page.locator('#planHours')).toBeVisible({ timeout: 15000 });
// Fill all 5 shifts.
for (const s of allFiveShifts) {
await setShift(page, s.id, s.start, s.end, s.break);
}
// Save.
const updatePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT',
{ timeout: 30000 });
const reindexPromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST',
{ timeout: 30000 });
await page.locator('#saveButton').click();
await updatePromise;
await reindexPromise;
await waitForSpinner(page);
await page.waitForTimeout(500);
// Re-open the same cell and assert every shift round-tripped —
// this is the bit that failed before the fix: shifts 3-5 came back as 00:00.
await page.locator(cellId).scrollIntoViewIfNeeded();
await page.locator(cellId).click();
await expect(page.locator('#planHours')).toBeVisible({ timeout: 15000 });
// Per-assertion 30s timeouts — the dialog opens before the form's
// reactive bindings finish hydrating from the just-saved planning,
// so the 5s default toHaveValue retry can race the bind. The
// assertion contract is unchanged; only the retry budget grew.
for (const s of allFiveShifts) {
await expect(
page.locator(`[data-testid="plannedStartOfShift${s.id}"]`),
`shift ${s.id} start should round-trip`
).toHaveValue(s.start, { timeout: 30000 });
await expect(
page.locator(`[data-testid="plannedEndOfShift${s.id}"]`),
`shift ${s.id} end should round-trip`
).toHaveValue(s.end, { timeout: 30000 });
await expect(
page.locator(`[data-testid="plannedBreakOfShift${s.id}"]`),
`shift ${s.id} break should round-trip`
).toHaveValue(s.break, { timeout: 30000 });
}
await page.locator('#cancelButton').click();
});
/**
* Regression guard for the assigned-site dialog "edit past registrations"
* radio group: it must remain visible and editable when entry method is
* acceptPlanned. Prior bug: an *ngIf clause in the template hid the entire
* editing-policy section under acceptPlanned mode, even though the server
* persists allowEditOfRegistrations independently of allowAcceptOfPlannedHours.
*/
test('editing-policy stays visible and persists when acceptPlanned is selected', async ({ page }) => {
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
const indexPromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
await indexPromise;
await waitForSpinner(page);
// Open the assigned-site dialog for the third worker (matches the
// multishift test's #firstColumn3 convention so the two tests don't
// clobber each other across the same shard). Await the
// getAssignedSite GET so the dialog opens with hydrated data — the
// gated *ngIf inputs only attach once `data.*` is bound.
const getAssignedSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-sites')
&& r.url().includes('siteId=')
&& r.request().method() === 'GET',
{ timeout: 30000 });
await page.locator('#firstColumn3').click();
await getAssignedSitePromise;
await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 30000 });
// The entry-method + editing-policy radios are gated behind
// allowPersonalTimeRegistration. Make sure it's enabled (idempotent).
const personalCb = page.locator('#allowPersonalTimeRegistration input[type="checkbox"]');
await expect(personalCb).toBeAttached({ timeout: 30000 });
if (!(await personalCb.isChecked())) {
await page.locator('#allowPersonalTimeRegistration').click({ force: true });
}
await expect(personalCb).toBeChecked();
// Click the acceptPlanned radio — pick the inner clickable label/input
// because the Material radio button host wraps a hidden input.
const acceptPlannedRadio = page.locator('mat-radio-button[value="acceptPlanned"]');
await acceptPlannedRadio.scrollIntoViewIfNeeded();
await acceptPlannedRadio.locator('label').first().click({ force: true });
// Assert the editing-policy section is in the DOM. The two radio groups
// each render their own mat-radio-group; the second one carries the
// editing-policy values (locked / untilPayroll / twoDaysRolling).
await expect(page.locator('mat-radio-button[value="untilPayroll"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('mat-radio-button[value="twoDaysRolling"]')).toBeVisible();
// Pick "Yes, until the last payroll run" (untilPayroll).
await page.locator('mat-radio-button[value="untilPayroll"]').locator('label').first()
.click({ force: true });
// Save and wait for the PUT to land + the dashboard re-index.
const assignSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT',
{ timeout: 30000 });
await page.locator('#saveButton').click({ force: true });
await assignSitePromise;
await waitForSpinner(page);
await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 15000 });
// Re-open the dialog and assert both choices round-tripped. Wait for
// the freshly-fetched assigned-site GET so the radios bind to the
// persisted values before we assert.
const getAssignedSitePromise2 = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-sites')
&& r.url().includes('siteId=')
&& r.request().method() === 'GET',
{ timeout: 30000 });
await page.locator('#firstColumn3').click();
await getAssignedSitePromise2;
await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 30000 });
// acceptPlanned still selected.
await expect(
page.locator('mat-radio-button[value="acceptPlanned"] input[type="radio"]'),
).toBeChecked();
// Editing-policy section is still rendered (the previously-broken case)…
await expect(page.locator('mat-radio-button[value="untilPayroll"]')).toBeVisible();
// …and untilPayroll is the persisted choice.
await expect(
page.locator('mat-radio-button[value="untilPayroll"] input[type="radio"]'),
).toBeChecked();
await page.locator('#cancelButton').click();
});
/**
* Regression guard for the "Use 1-minute intervals" first-user toggle in
* the Advanced settings section. The flag rides on AssignedSite end-to-end
* (entity → DTO → write mapping → angular model) and is persisted by
* TimeSettingService.UpdateAssignedSite. This test only verifies the UI
* plumbing — the toggle is dormant (no business-logic consumers yet).
*
* Gating: !data.resigned && (selectCurrentUserIsFirstUser$ | async).
* The CI seed logs in as admin@admin.com (LoginConstants.username), which
* is the first-user, so the toggle is visible in this test context.
*/
test('first-user can toggle Use 1-minute intervals; persists across save+reopen', async ({ page }) => {
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
const indexPromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
await indexPromise;
await waitForSpinner(page);
// Open the assigned-site dialog for the third worker — same convention
// as the other tests on this shard so they don't clobber each other.
// Await the GET that hydrates the dialog so first-user-gated toggle
// attaches synchronously after open.
const getAssignedSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-sites')
&& r.url().includes('siteId=')
&& r.request().method() === 'GET',
{ timeout: 30000 });
await page.locator('#firstColumn3').click();
await getAssignedSitePromise;
await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 30000 });
// The toggle is gated behind first-user; the CI fixture logs in as
// first-user, so it must be visible.
await expect(page.locator('#useOneMinuteIntervals')).toBeVisible({ timeout: 30000 });
const cb = page.locator('#useOneMinuteIntervals input[type="checkbox"]');
await expect(cb).toBeAttached({ timeout: 30000 });
// Capture the starting state, flip it, save, re-open, assert it persisted.
const wasChecked = await cb.isChecked();
await page.locator('#useOneMinuteIntervals').click({ force: true });
if (wasChecked) {
await expect(cb).not.toBeChecked();
} else {
await expect(cb).toBeChecked();
}
const assignSitePromise = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT',
{ timeout: 30000 });
await page.locator('#saveButton').click({ force: true });
await assignSitePromise;
await waitForSpinner(page);
await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 15000 });
// Re-open and assert the new value round-tripped. Await the
// post-save GET so the freshly-persisted toggle is bound before we
// assert (prior failure: line 305 `toBeChecked` saw the pre-bind
// unchecked state in a 5s window).
const getAssignedSitePromise2 = page.waitForResponse(
r => r.url().includes('/api/time-planning-pn/settings/assigned-sites')
&& r.url().includes('siteId=')
&& r.request().method() === 'GET',
{ timeout: 30000 });
await page.locator('#firstColumn3').click();
await getAssignedSitePromise2;
await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 30000 });
const cbReopened = page.locator('#useOneMinuteIntervals input[type="checkbox"]');
await expect(cbReopened).toBeAttached({ timeout: 30000 });
if (wasChecked) {
await expect(cbReopened).not.toBeChecked({ timeout: 15000 });
} else {
await expect(cbReopened).toBeChecked({ timeout: 15000 });
}
await page.locator('#cancelButton').click();
});
/**
* Phase 4 — second-precision DISPLAY: when a row's site has
* `useOneMinuteIntervals = true` AND the planning has a precise
* `start1StartedAt` stamp (e.g. 07:03:53), the plannings-table cell must
* render the stamp at HH:mm:ss instead of the legacy HH:mm.
*
* Server-side seeding `AssignedSite.UseOneMinuteIntervals = true` plus
* a planning with `Start1StartedAt = 2026-05-15 07:03:53` requires DB
* fixture work the CI playwright shard doesn't yet wire up (the tests
* here log in as admin and rely on the default seed). Captured here as
* a TODO so the assertion shape survives any future fixture work; the
* Phase 4 jest unit test on `formatStamp(...)` covers the contract for
* the merge-blocking path.
*/
test.skip('plannings-table renders HH:mm:ss for actual stamp when site flag is on', async ({ page }) => {
// TODO(phase 4 fixture): seed AssignedSite.UseOneMinuteIntervals = true
// for the worker referenced by #cell3_0 AND a PlanRegistration row with
// Start1StartedAt = '2026-05-15T07:03:53Z' on a date that lands inside
// the dashboard's default visible range.
//
// Then the assertion shape is:
//
// await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
// await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
// await waitForSpinner(page);
//
// const cellId = '#cell3_0';
// await page.locator(cellId).scrollIntoViewIfNeeded();
//
// // The first-shift actual line is rendered with id firstShiftActual{rowIdx}_{colField}.
// const firstShiftActual = page.locator('[id^="firstShiftActual"]').first();
// await expect(firstShiftActual).toContainText('07:03:53');
// // Negative guard — the legacy 5-min path would render '07:00' / '07:05' instead.
// await expect(firstShiftActual).not.toContainText(/07:0[05]\s/);
//
// Until the fixture lands the unit test
// `formatStamp (Phase 4) — uses HH:mm:ss format when row.useOneMinuteIntervals is true`
// covers the format-helper contract (eform-client/src/app/plugins/modules/time-planning-pn/
// components/plannings/time-plannings-table/time-plannings-table.component.spec.ts).
});
});