Skip to content

Commit a3c9000

Browse files
renemadsenclaude
andcommitted
feat(b1m): re-add dashboard-edit-{a,b} clones now that shifts 3-5 enabled (FU-B)
The b1m variants of dashboard-edit-a and dashboard-edit-b were dropped in PR #1545 (commits 35a2238 and 6acff72) because `waitForResponse` for the post-save `/plannings/index` POST timed out — the server-side Update returned `success:false` so no re-index POST followed. Three server-side fixes shipped earlier didn't fully resolve it: • #1546 — currentUserAsync.Id NRE on UpdatePlanning • #1547 — model null-guard on UpdatePlanning request • #1556 — FU-A: post-migration patch flips ThirdShiftActive / FourthShiftActive / FifthShiftActive on every active assigned site so shifts 3-5 inputs render in the workday-entity dialog The remaining hypothesis was that the legacy two-shift fill (shift1 + shift2 only) caused the FE to PUT a body with null shift3-5 fields, which the server's compute path operated on and silently failed. With FU-A's seed extension shifts 3-5 inputs now render — FU-B (this PR) fills all five shifts ascending so the PUT body always carries every non-null shift cell, matching the b1m multishift-shape pattern. Changes: • Extended OFFGRID_TIMES helper with shifts 3-5 (off-grid, ascending, every value non-multiple-of-5; shift{n+1}.start >= shift{n}.stop). shift2End shifted 16:23 → 14:23 to make room for shifts 3-5. • Re-added b1m/dashboard-edit-a.spec.ts: planned-shift round-trip, fills all five shifts, asserts every value round-trips. • Re-added b1m/dashboard-edit-b.spec.ts: actual-stamp round-trip with HH:mm:ss display assertion on `firstShiftActual3_0`. Also fills all five planned shifts before setting actual stamps so the PUT body stays fully populated. Both specs use `await expect(page.locator('#saveButton')).toBeEnabled(...)` before the save click (cross-shift validators run async after each pick; clicking a disabled <button> drops the event in the browser even with Playwright's force:true, which is what hung the dropped clones). CI matrix entry for `b1m` already exists in dotnet-core-pr.yml + dotnet-core-master.yml, no workflow change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7aef4ec commit a3c9000

3 files changed

Lines changed: 404 additions & 1 deletion

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { LoginPage } from '../../../Page objects/Login.page';
3+
import { OFFGRID_TIMES } from '../../../../helpers/one-minute-times';
4+
5+
/**
6+
* b1m variant of `b/dashboard-edit-a.spec.ts`: exercises the planned-shift
7+
* edit + round-trip flow with off-grid (NOT 5-min-aligned) time literals.
8+
* Relies on the b1m `post-migration.sql` patch to flip
9+
* `AssignedSites.UseOneMinuteIntervals = 1` (so the Material timepicker
10+
* runs at `minutesGap=1` and the position-based picker can land on any
11+
* minute 0-59), and to flip ThirdShiftActive / FourthShiftActive /
12+
* FifthShiftActive = 1 (FU-A) so the workday-entity dialog renders
13+
* shift 3-5 inputs without an in-spec settings dance.
14+
*
15+
* FU-B history: this clone was dropped twice (PR #1545 → commit 35a22382)
16+
* because the legacy two-shift fill (shift1 + shift2 only) caused the
17+
* server-side Update path to compute over null shift3-5 fields and
18+
* silently fail (Save returned `success:false`, no `/plannings/index`
19+
* POST followed, `waitForResponse` hung). FU-A enables shifts 3-5 in
20+
* the seed; the remaining fix is to FILL all five shifts ascending so
21+
* the PUT body always carries every non-null shift cell — matching the
22+
* b1m multishift-shape pattern. PRs #1546 (currentUserAsync.Id) and
23+
* #1547 (model null-guard) shipped earlier server-side fixes; FU-A
24+
* (#1556) extended the seed; FU-B (this spec) drives the full five-
25+
* shift PUT body so all three fixes are exercised end-to-end.
26+
*
27+
* Scope: form-input round-trip on flag-on rows. Cumulative-flex math
28+
* stays on the legacy `b/` shard.
29+
*/
30+
31+
async function waitForSpinner(page: Page) {
32+
if (await page.locator('.overlay-spinner').count() > 0) {
33+
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
34+
}
35+
}
36+
37+
async function pickTime(page: Page, timeStr: string) {
38+
// Position-based clock-face clicks (cloned from b/dashboard-edit-multishift's
39+
// pickTime helper). Works at minute granularity because the position math
40+
// is `minuteAngle = m * 6`, no rotateZ-selector dependency.
41+
const [hourStr, minuteStr] = timeStr.split(':');
42+
const h = parseInt(hourStr, 10);
43+
const m = parseInt(minuteStr, 10);
44+
45+
const cx = 145, cy = 145;
46+
47+
const hourFace = page.locator('.clock-face');
48+
await hourFace.first().waitFor({ state: 'visible', timeout: 5000 });
49+
const hourAngle = (h % 12) * 30;
50+
const hourR = (h === 0 || h > 12) ? 60 : 100;
51+
const hourRad = hourAngle * Math.PI / 180;
52+
await hourFace.first().click({
53+
position: {
54+
x: Math.round(cx + hourR * Math.sin(hourRad)),
55+
y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0),
56+
},
57+
});
58+
59+
await page.waitForTimeout(500);
60+
const minuteFace = page.locator('.clock-face');
61+
await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 });
62+
const minuteAngle = m * 6;
63+
const minuteR = 100;
64+
const minuteRad = minuteAngle * Math.PI / 180;
65+
await minuteFace.first().click({
66+
position: {
67+
x: Math.round(cx + minuteR * Math.sin(minuteRad)),
68+
y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0),
69+
},
70+
});
71+
72+
await page.waitForTimeout(500);
73+
await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click();
74+
}
75+
76+
async function setPlannedShift(
77+
page: Page,
78+
shiftId: 1|2|3|4|5,
79+
start: string,
80+
end: string,
81+
breakStr: string,
82+
) {
83+
await page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`).click();
84+
await pickTime(page, start);
85+
await expect(page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`)).toHaveValue(start);
86+
87+
await page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`).click();
88+
await pickTime(page, end);
89+
await expect(page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`)).toHaveValue(end);
90+
91+
await page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`).click();
92+
await pickTime(page, breakStr);
93+
await expect(page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`)).toHaveValue(breakStr);
94+
}
95+
96+
// All five shifts ascending, off-grid. Shifts 3-5 share a single break value
97+
// with shifts 1-2 — the `break` field is intentionally a single canonical
98+
// constant so adding new shifts doesn't mean adding new break literals (the
99+
// workday-entity-dialog has one break input per shift, but the value is the
100+
// same for every shift in this round-trip; what we're testing is the
101+
// position-based picker round-trip, not break-per-shift uniqueness).
102+
const allFivePlannedShifts = [
103+
{ id: 1 as const, start: OFFGRID_TIMES.shift1Start, end: OFFGRID_TIMES.shift1End, break: OFFGRID_TIMES.break },
104+
{ id: 2 as const, start: OFFGRID_TIMES.shift2Start, end: OFFGRID_TIMES.shift2End, break: OFFGRID_TIMES.break },
105+
{ id: 3 as const, start: OFFGRID_TIMES.shift3Start, end: OFFGRID_TIMES.shift3End, break: OFFGRID_TIMES.break },
106+
{ id: 4 as const, start: OFFGRID_TIMES.shift4Start, end: OFFGRID_TIMES.shift4End, break: OFFGRID_TIMES.break },
107+
{ id: 5 as const, start: OFFGRID_TIMES.shift5Start, end: OFFGRID_TIMES.shift5End, break: OFFGRID_TIMES.break },
108+
];
109+
110+
test.describe('Dashboard edit values (b1m, flag-on, 1-minute granularity)', () => {
111+
test.beforeEach(async ({ page }) => {
112+
await page.goto('http://localhost:4200');
113+
await new LoginPage(page).login();
114+
});
115+
116+
test('persists off-grid planned shifts 1-5 through save + reopen', async ({ page }) => {
117+
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
118+
const indexPromise = page.waitForResponse(r =>
119+
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
120+
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
121+
await indexPromise;
122+
await waitForSpinner(page);
123+
124+
const cellId = '#cell3_0';
125+
await page.locator(cellId).scrollIntoViewIfNeeded();
126+
await page.locator(cellId).click();
127+
await expect(page.locator('#planHours')).toBeVisible();
128+
129+
// Fill all five shifts ascending. Shifts 3-5 inputs render because the
130+
// b1m post-migration patch (FU-A) sets ThirdShiftActive /
131+
// FourthShiftActive / FifthShiftActive = 1 on every active assigned
132+
// site, so the dialog template's `*ngIf="thirdShiftActive"` (etc.)
133+
// evaluates true on first render — no in-spec settings dance needed.
134+
for (const s of allFivePlannedShifts) {
135+
await setPlannedShift(page, s.id, s.start, s.end, s.break);
136+
}
137+
138+
// Save. Wait for the button to lose `disabled` (the form's cross-shift
139+
// validators run async after each pick) before clicking — `force: true`
140+
// would let Playwright dispatch the event but the browser still drops
141+
// clicks on disabled <button>s, so the request never fires and the
142+
// waitForResponse below would just hang.
143+
await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 });
144+
const updatePromise = page.waitForResponse(r =>
145+
r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT');
146+
const reindexPromise = page.waitForResponse(r =>
147+
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
148+
await page.locator('#saveButton').click();
149+
await updatePromise;
150+
await reindexPromise;
151+
await waitForSpinner(page);
152+
await page.waitForTimeout(500);
153+
154+
// Re-open and assert every off-grid value round-tripped.
155+
await page.locator(cellId).scrollIntoViewIfNeeded();
156+
await page.locator(cellId).click();
157+
await expect(page.locator('#planHours')).toBeVisible();
158+
159+
for (const s of allFivePlannedShifts) {
160+
await expect(
161+
page.locator(`[data-testid="plannedStartOfShift${s.id}"]`),
162+
`shift ${s.id} start should round-trip`,
163+
).toHaveValue(s.start);
164+
await expect(
165+
page.locator(`[data-testid="plannedEndOfShift${s.id}"]`),
166+
`shift ${s.id} end should round-trip`,
167+
).toHaveValue(s.end);
168+
await expect(
169+
page.locator(`[data-testid="plannedBreakOfShift${s.id}"]`),
170+
`shift ${s.id} break should round-trip`,
171+
).toHaveValue(s.break);
172+
}
173+
174+
await page.locator('#cancelButton').click();
175+
});
176+
});
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { LoginPage } from '../../../Page objects/Login.page';
3+
import { OFFGRID_TIMES } from '../../../../helpers/one-minute-times';
4+
5+
/**
6+
* b1m variant of `b/dashboard-edit-b.spec.ts`: edits the ACTUAL stamps
7+
* (start1StartedAt / stop1StoppedAt / pause1Id) at minute granularity and
8+
* round-trips them. Phase 4 added second-precision DISPLAY for actual stamps
9+
* when the row's site has `useOneMinuteIntervals = true` — the dashboard
10+
* cell renders `formatStamp(...)` and `getStopTimeDisplayWithSeconds(...)`
11+
* with `HH:mm:ss` format on flag-on rows.
12+
*
13+
* The form-input values themselves stay `HH:mm` (Phase 4 didn't widen the
14+
* time-picker form binding), so input-value assertions remain `HH:mm`.
15+
* The cell-text assertion below is the bit that exercises the second-
16+
* precision display path.
17+
*
18+
* FU-B history: this clone was dropped (PR #1545 → commit 6acff720)
19+
* because `waitForResponse('/plannings/index')` timed out on save —
20+
* server-side Update returned `success:false` so no `/plannings/index`
21+
* POST followed. PRs #1546 (currentUserAsync.Id) and #1547 (model
22+
* null-guard) shipped server-side fixes; FU-A (#1556) extended the
23+
* b1m seed to flip ThirdShiftActive / FourthShiftActive /
24+
* FifthShiftActive = 1 so the dialog renders shift 3-5 inputs without
25+
* an in-spec settings dance. The remaining hypothesis was that the
26+
* legacy two-shift fill caused the server-side compute to operate on
27+
* null shift3-5 fields and silently fail — FU-B (this clone) fills
28+
* all five planned shifts ascending before setting actual stamps so
29+
* the PUT body always carries every non-null shift cell.
30+
*/
31+
32+
async function waitForSpinner(page: Page) {
33+
if (await page.locator('.overlay-spinner').count() > 0) {
34+
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
35+
}
36+
}
37+
38+
async function pickTime(page: Page, timeStr: string) {
39+
const [hourStr, minuteStr] = timeStr.split(':');
40+
const h = parseInt(hourStr, 10);
41+
const m = parseInt(minuteStr, 10);
42+
43+
const cx = 145, cy = 145;
44+
45+
const hourFace = page.locator('.clock-face');
46+
await hourFace.first().waitFor({ state: 'visible', timeout: 5000 });
47+
const hourAngle = (h % 12) * 30;
48+
const hourR = (h === 0 || h > 12) ? 60 : 100;
49+
const hourRad = hourAngle * Math.PI / 180;
50+
await hourFace.first().click({
51+
position: {
52+
x: Math.round(cx + hourR * Math.sin(hourRad)),
53+
y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0),
54+
},
55+
});
56+
57+
await page.waitForTimeout(500);
58+
const minuteFace = page.locator('.clock-face');
59+
await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 });
60+
const minuteAngle = m * 6;
61+
const minuteR = 100;
62+
const minuteRad = minuteAngle * Math.PI / 180;
63+
await minuteFace.first().click({
64+
position: {
65+
x: Math.round(cx + minuteR * Math.sin(minuteRad)),
66+
y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0),
67+
},
68+
});
69+
70+
await page.waitForTimeout(500);
71+
await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click();
72+
}
73+
74+
async function setPlannedShift(
75+
page: Page,
76+
shiftId: 1|2|3|4|5,
77+
start: string,
78+
end: string,
79+
breakStr: string,
80+
) {
81+
await page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`).click();
82+
await pickTime(page, start);
83+
await expect(page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`)).toHaveValue(start);
84+
85+
await page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`).click();
86+
await pickTime(page, end);
87+
await expect(page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`)).toHaveValue(end);
88+
89+
await page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`).click();
90+
await pickTime(page, breakStr);
91+
await expect(page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`)).toHaveValue(breakStr);
92+
}
93+
94+
// Same five-shift block the b1m/dashboard-edit-a clone uses — keeps the
95+
// PUT body fully populated so the server-side Update path never operates
96+
// on null shift3-5 fields. The actual stamps below are set on shift 1
97+
// only (mirrors the legacy `b/dashboard-edit-b.spec.ts` convention; the
98+
// HH:mm:ss display assertion targets `firstShiftActual3_0`).
99+
const allFivePlannedShifts = [
100+
{ id: 1 as const, start: OFFGRID_TIMES.shift1Start, end: OFFGRID_TIMES.shift1End, break: OFFGRID_TIMES.break },
101+
{ id: 2 as const, start: OFFGRID_TIMES.shift2Start, end: OFFGRID_TIMES.shift2End, break: OFFGRID_TIMES.break },
102+
{ id: 3 as const, start: OFFGRID_TIMES.shift3Start, end: OFFGRID_TIMES.shift3End, break: OFFGRID_TIMES.break },
103+
{ id: 4 as const, start: OFFGRID_TIMES.shift4Start, end: OFFGRID_TIMES.shift4End, break: OFFGRID_TIMES.break },
104+
{ id: 5 as const, start: OFFGRID_TIMES.shift5Start, end: OFFGRID_TIMES.shift5End, break: OFFGRID_TIMES.break },
105+
];
106+
107+
test.describe('Dashboard edit actual stamps (b1m, flag-on, 1-minute granularity)', () => {
108+
test.beforeEach(async ({ page }) => {
109+
await page.goto('http://localhost:4200');
110+
await new LoginPage(page).login();
111+
});
112+
113+
test('persists off-grid actual start/stop and renders HH:mm:ss in dashboard cell', async ({ page }) => {
114+
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
115+
const indexPromise = page.waitForResponse(r =>
116+
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
117+
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
118+
// Step backwards into last-week so the cell falls in the past
119+
// (firstShiftActual is rendered for any day that has a start1StartedAt
120+
// value, but past dates render the cumulative flex too — keeping the
121+
// assertion stack aligned with the legacy `b` shard's range).
122+
await page.locator('#backwards').click();
123+
await indexPromise;
124+
await waitForSpinner(page);
125+
126+
const cellId = '#cell3_0';
127+
await page.locator(cellId).scrollIntoViewIfNeeded();
128+
await page.locator(cellId).click();
129+
await expect(page.locator('#planHours')).toBeVisible();
130+
await page.waitForTimeout(500);
131+
132+
// Fill all five planned shifts ascending so the PUT body always
133+
// carries non-null shift3-5 fields. Shifts 3-5 inputs render because
134+
// the b1m post-migration patch (FU-A) sets ThirdShiftActive /
135+
// FourthShiftActive / FifthShiftActive = 1 on every active assigned
136+
// site.
137+
for (const s of allFivePlannedShifts) {
138+
await setPlannedShift(page, s.id, s.start, s.end, s.break);
139+
}
140+
141+
// Set the actual stamps for shift 1 with off-grid times. Pause IS set
142+
// here (matching the legacy `b/dashboard-edit-b.spec.ts` convention) —
143+
// the server's UpdatePlanning path NREs on null `pause1Id` when the row
144+
// has off-grid actual stamps, but that's an orthogonal Phase 0-4 path
145+
// and out of scope for this PR. Setting pause sidesteps it entirely.
146+
await page.locator('[data-testid="start1StartedAt"]').click();
147+
await pickTime(page, OFFGRID_TIMES.shift1Start);
148+
await expect(page.locator('[data-testid="start1StartedAt"]')).toHaveValue(OFFGRID_TIMES.shift1Start);
149+
150+
await page.locator('[data-testid="stop1StoppedAt"]').click();
151+
await pickTime(page, OFFGRID_TIMES.shift1End);
152+
await expect(page.locator('[data-testid="stop1StoppedAt"]')).toHaveValue(OFFGRID_TIMES.shift1End);
153+
154+
await page.locator('[data-testid="pause1Id"]').click();
155+
await pickTime(page, OFFGRID_TIMES.break);
156+
await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue(OFFGRID_TIMES.break);
157+
158+
// Save. Wait for the button to lose `disabled` (the form's cross-shift
159+
// validators run async after each pick) before clicking — `force: true`
160+
// would let Playwright dispatch the event but the browser still drops
161+
// clicks on disabled <button>s, so the request never fires and the
162+
// waitForResponse below would just hang.
163+
await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 });
164+
const updatePromise = page.waitForResponse(r =>
165+
r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT');
166+
const reindexPromise = page.waitForResponse(r =>
167+
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
168+
await page.locator('#saveButton').click();
169+
await updatePromise;
170+
await reindexPromise;
171+
await waitForSpinner(page);
172+
await page.waitForTimeout(1000);
173+
174+
// Phase 4 second-precision DISPLAY assertion: cell text must include
175+
// HH:mm:ss for both the start and the stop stamp. The user picked
176+
// shift1Start / shift1End, the form-binding stores `:00` seconds, the
177+
// dashboard renders via formatStamp(...) which returns `HH:mm:ss` on
178+
// flag-on rows.
179+
const firstShiftActualLocator = page.locator('#firstShiftActual3_0');
180+
await firstShiftActualLocator.scrollIntoViewIfNeeded();
181+
await expect(firstShiftActualLocator).toContainText(`${OFFGRID_TIMES.shift1Start}:00`, { timeout: 15000 });
182+
await expect(firstShiftActualLocator).toContainText(`${OFFGRID_TIMES.shift1End}:00`);
183+
// Negative guard — the legacy 5-min path would render bare HH:mm.
184+
// (We can't simply assert ".not.toContainText('08:01 ')" because the
185+
// HH:mm:ss form is a strict superset; instead verify seconds exist.)
186+
187+
// Re-open the cell and assert form-input values round-tripped at HH:mm.
188+
await page.locator(cellId).scrollIntoViewIfNeeded();
189+
await page.locator(cellId).click();
190+
await expect(page.locator('#planHours')).toBeVisible();
191+
192+
await expect(page.locator('[data-testid="start1StartedAt"]')).toHaveValue(OFFGRID_TIMES.shift1Start);
193+
await expect(page.locator('[data-testid="stop1StoppedAt"]')).toHaveValue(OFFGRID_TIMES.shift1End);
194+
await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue(OFFGRID_TIMES.break);
195+
196+
// Planned shifts 1-5 must have round-tripped too — the FU-B premise
197+
// is that filling all five before setting actual stamps keeps the
198+
// PUT body fully populated, so the round-trip should preserve every
199+
// shift cell.
200+
for (const s of allFivePlannedShifts) {
201+
await expect(
202+
page.locator(`[data-testid="plannedStartOfShift${s.id}"]`),
203+
`shift ${s.id} planned start should round-trip`,
204+
).toHaveValue(s.start);
205+
await expect(
206+
page.locator(`[data-testid="plannedEndOfShift${s.id}"]`),
207+
`shift ${s.id} planned end should round-trip`,
208+
).toHaveValue(s.end);
209+
}
210+
211+
await page.locator('#cancelButton').click();
212+
});
213+
});

0 commit comments

Comments
 (0)