Skip to content

Commit 9067725

Browse files
authored
Merge pull request #1551 from microting/feat/use-one-minute-intervals-phase-5-pr5-f1m
Phase 5 PR 5: f1m playwright variant — UseOneMinuteIntervals=true
2 parents 3e603f3 + a9bf842 commit 9067725

7 files changed

Lines changed: 3614 additions & 2 deletions

File tree

.github/workflows/dotnet-core-master.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ jobs:
8888
strategy:
8989
fail-fast: false
9090
matrix:
91-
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,b1m,c1m,d1m,e1m]
91+
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,b1m,c1m,d1m,e1m,f1m]
9292
steps:
9393
- uses: actions/checkout@v3
9494
with:

.github/workflows/dotnet-core-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ jobs:
8181
strategy:
8282
fail-fast: false
8383
matrix:
84-
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,b1m,c1m,d1m,e1m]
84+
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,b1m,c1m,d1m,e1m,f1m]
8585
steps:
8686
- uses: actions/checkout@v3
8787
with:

eform-client/playwright/e2e/plugins/time-planning-pn/f1m/420_SDK.sql

Lines changed: 2778 additions & 0 deletions
Large diffs are not rendered by default.

eform-client/playwright/e2e/plugins/time-planning-pn/f1m/420_eform-angular-time-planning-plugin.sql

Lines changed: 518 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { LoginPage } from '../../../Page objects/Login.page';
3+
import { OFFGRID_TIMES_F1M } from '../../../../helpers/one-minute-times';
4+
5+
/**
6+
* f1m variant of the SAVE-FAILURE / validation regression suite cloned from
7+
* `f/dashboard-edit-a.spec.ts`. Where b1m / c1m / d1m / e1m clone the
8+
* positive-path multi-shift round-trip from the `b` shard (with five
9+
* ascending off-grid shifts), f1m exercises the NEGATIVE paths: each test
10+
* fills shift 1 with an invalid time pair (stop-before-start, same
11+
* start/stop, pause-longer-than-shift) and asserts the corresponding
12+
* validator surfaces its Danish error message.
13+
*
14+
* Why this matters for the flag-on matrix entry:
15+
* The `workday-entity-dialog`'s `plannedShiftDurationValidator` and
16+
* `actualShiftDurationValidator` run on raw `HH:mm` form values via
17+
* `getMinutes()` — they don't go through the 5-min `convertTimeToMinutes`
18+
* storage path. So the validators MUST trigger identical errors whether
19+
* inputs land on a 5-min grid (`f`) or off-grid (`f1m`); a regression
20+
* that makes the validator depend on the storage quantization would
21+
* silently break the flag-on form. f1m guards that contract.
22+
*
23+
* Shared with b1m / c1m / d1m / e1m:
24+
* • Same baseline seed (`420_eform-angular-time-planning-plugin.sql` and
25+
* `420_SDK.sql` are copies of `a/`).
26+
* • Same `post-migration.sql` flipping `UseOneMinuteIntervals = 1` for
27+
* every active assigned site (the workflow's generic post-migration
28+
* step picks this up automatically).
29+
*
30+
* What's new in f1m: a dedicated `OFFGRID_TIMES_F1M` block in the shared
31+
* helper that pairs each validation case with off-grid (non-multiple-of-5)
32+
* minutes which still preserve the same INVALID RELATIONSHIP that the
33+
* legacy `f`-shard test relied on. E.g. `'10:23' > '09:17'` trips
34+
* `invalidRange` exactly the same way `'10:00' > '09:00'` does.
35+
*
36+
* Math tests (positive path): the two midnight-wrap cases at the end use
37+
* an off-grid pair `00:00 ↔ 02:24` (144 min one way, 1296 min the other)
38+
* so the recomputed `planHours` lands on a clean fractional value
39+
* (`2.4` / `21.6`) rather than the integer `2` / `22` the legacy `f`
40+
* shard asserted. `todaysFlex` stays `'0.00'` because actual quantization
41+
* (still 5-min internally) round-trips symmetrically with the planned
42+
* value at this exact pair (see comment block on the test for the
43+
* arithmetic).
44+
*
45+
* NOT cloned from `f/`:
46+
* • The two `test.skip(...)` cases for break-too-long-on-planned and
47+
* shift2-overlapping-shift1 — kept as `.skip` here too so the file
48+
* remains a structural mirror.
49+
*/
50+
51+
async function waitForSpinner(page: Page) {
52+
if (await page.locator('.overlay-spinner').count() > 0) {
53+
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
54+
}
55+
}
56+
57+
/**
58+
* Position-based clock-face picker. Identical helper to the b1m / c1m /
59+
* d1m / e1m specs — works uniformly for h=0 (break and midnight times)
60+
* unlike rotateZ-selector strategies that fail on the inner-ring `00`
61+
* position.
62+
*/
63+
async function pickTime(page: Page, timeStr: string) {
64+
const [hourStr, minuteStr] = timeStr.split(':');
65+
const h = parseInt(hourStr, 10);
66+
const m = parseInt(minuteStr, 10);
67+
68+
const cx = 145, cy = 145;
69+
70+
const hourFace = page.locator('.clock-face');
71+
await hourFace.first().waitFor({ state: 'visible', timeout: 5000 });
72+
const hourAngle = (h % 12) * 30;
73+
const hourR = (h === 0 || h > 12) ? 60 : 100;
74+
const hourRad = hourAngle * Math.PI / 180;
75+
await hourFace.first().click({
76+
position: {
77+
x: Math.round(cx + hourR * Math.sin(hourRad)),
78+
y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0),
79+
},
80+
});
81+
82+
await page.waitForTimeout(500);
83+
const minuteFace = page.locator('.clock-face');
84+
await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 });
85+
const minuteAngle = m * 6;
86+
const minuteR = 100;
87+
const minuteRad = minuteAngle * Math.PI / 180;
88+
await minuteFace.first().click({
89+
position: {
90+
x: Math.round(cx + minuteR * Math.sin(minuteRad)),
91+
y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0),
92+
},
93+
});
94+
95+
await page.waitForTimeout(500);
96+
await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click();
97+
// Wait for the timepicker overlay to fully close before the next pick.
98+
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
99+
await page.waitForTimeout(500);
100+
}
101+
102+
/** Open the timepicker for `selector` and pick `timeStr`. */
103+
async function setTimepickerValue(page: Page, selector: string, timeStr: string) {
104+
await page.locator(`[data-testid="${selector}"]`).click();
105+
await pickTime(page, timeStr);
106+
}
107+
108+
/** Wait for and assert a Danish validator error on the given input. */
109+
async function assertInputError(page: Page, errorTestId: string, expectedMessage: string) {
110+
const errorLocator = page.locator(`[data-testid="${errorTestId}"]`).first();
111+
await errorLocator.waitFor({ state: 'visible', timeout: 15000 });
112+
await expect(errorLocator).toContainText(expectedMessage);
113+
}
114+
115+
test.describe('Dashboard edit values (f1m, flag-on, off-grid validation pairs)', () => {
116+
test.beforeEach(async ({ page }) => {
117+
await page.goto('http://localhost:4200');
118+
await new LoginPage(page).login();
119+
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
120+
121+
const indexUpdatePromise = page.waitForResponse(
122+
r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'
123+
);
124+
125+
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
126+
await indexUpdatePromise;
127+
await waitForSpinner(page);
128+
129+
await page.locator('#workingHoursSite').click();
130+
await page.locator('.ng-option').filter({ hasText: 'ac ad' }).click();
131+
await page.locator('#cell0_0').click();
132+
133+
// The `a/`-style seed pre-fills shift-1 planned + actual-1 start, so we
134+
// wipe them via the row-level delete icon to start each test from a
135+
// known-empty pair (mirrors the legacy `f` shard's beforeEach exactly).
136+
for (const selector of ['plannedStartOfShift1', 'start1StartedAt']) {
137+
const newSelector = `[data-testid="${selector}"]`;
138+
await page.locator(newSelector)
139+
.locator('xpath=ancestor::div[contains(@class,"flex-row")]')
140+
.locator('button mat-icon')
141+
.filter({ hasText: 'delete' })
142+
.click({ force: true });
143+
await page.waitForTimeout(500);
144+
}
145+
});
146+
147+
// --- Planned Shift Duration Validator ---
148+
test('should show an error when planned stop time is before start time', async ({ page }) => {
149+
const t = OFFGRID_TIMES_F1M.plannedStopBefore;
150+
await setTimepickerValue(page, 'plannedStartOfShift1', t.start);
151+
await setTimepickerValue(page, 'plannedEndOfShift1', t.stop);
152+
await assertInputError(page, 'plannedEndOfShift1-Error', 'Stop må ikke være før start');
153+
});
154+
155+
test.skip('should show an error when planned break is longer than the shift duration', async ({ page }) => {
156+
// Skipped in the legacy `f` shard too — preserved here as `.skip` so the
157+
// file remains a 1:1 structural mirror of `f/dashboard-edit-a.spec.ts`.
158+
await setTimepickerValue(page, 'plannedStartOfShift1', '01:03');
159+
await setTimepickerValue(page, 'plannedEndOfShift1', '10:17');
160+
await setTimepickerValue(page, 'plannedBreakOfShift1', '09:43');
161+
await assertInputError(page, 'plannedBreakOfShift1-Error', 'Pausen må ikke være lige så lang som eller længere end skiftets varighed');
162+
});
163+
164+
test('should show an error when planned start and stop are the same', async ({ page }) => {
165+
const t = OFFGRID_TIMES_F1M.plannedSameTime;
166+
await setTimepickerValue(page, 'plannedStartOfShift1', t.start);
167+
await setTimepickerValue(page, 'plannedEndOfShift1', t.stop);
168+
await assertInputError(page, 'plannedEndOfShift1-Error', 'Start og stop kan ikke være det samme');
169+
});
170+
171+
// --- Actual Shift Duration Validator ---
172+
test('should show an error when actual stop time is before start time', async ({ page }) => {
173+
const t = OFFGRID_TIMES_F1M.actualStopBefore;
174+
await setTimepickerValue(page, 'start1StartedAt', t.start);
175+
await setTimepickerValue(page, 'stop1StoppedAt', t.stop);
176+
await setTimepickerValue(page, 'pause1Id', t.pause);
177+
await assertInputError(page, 'stop1StoppedAt-Error', 'Stop må ikke være før start');
178+
});
179+
180+
test('should show an error when actual pause is longer than the shift duration', async ({ page }) => {
181+
// 10:31 - 08:13 = 138 min shift duration; pause 02:18 = 138 min.
182+
// 138 ≥ 138 ⇒ `breakTooLong` validator fires (same boundary case as
183+
// legacy f-shard pair 8:00-10:00 / 2:00 ⇒ 120 ≥ 120). The pause
184+
// input's `[max]=getMaxDifference(start,stop)` caps the picker at the
185+
// shift duration, so we pick AT the cap to trigger the validator.
186+
const t = OFFGRID_TIMES_F1M.actualPauseTooLong;
187+
await setTimepickerValue(page, 'start1StartedAt', t.start);
188+
await setTimepickerValue(page, 'stop1StoppedAt', t.stop);
189+
await setTimepickerValue(page, 'pause1Id', t.pause);
190+
await assertInputError(page, 'pause1Id-Error', 'Pausen må ikke være lige så lang som eller længere end skiftets varighed');
191+
});
192+
193+
test('should show an error when actual start and stop are the same', async ({ page }) => {
194+
const t = OFFGRID_TIMES_F1M.actualSameTime;
195+
await setTimepickerValue(page, 'start1StartedAt', t.start);
196+
await setTimepickerValue(page, 'stop1StoppedAt', t.stop);
197+
await setTimepickerValue(page, 'pause1Id', t.pause);
198+
await assertInputError(page, 'stop1StoppedAt-Error', 'Start og stop kan ikke være det samme');
199+
});
200+
201+
// --- Shift-Wise Validator ---
202+
test.skip('should show an error if planned Shift 2 starts before planned Shift 1 ends', async ({ page }) => {
203+
// Skipped in the legacy `f` shard too — preserved here as `.skip` so the
204+
// file remains a 1:1 structural mirror.
205+
await setTimepickerValue(page, 'plannedStartOfShift1', '08:13');
206+
await setTimepickerValue(page, 'plannedEndOfShift1', '12:17');
207+
await setTimepickerValue(page, 'plannedStartOfShift2', '11:29');
208+
await assertInputError(page, 'plannedStartOfShift2-Error', 'Start kan ikke være tidligere end stop for den forrige skift');
209+
});
210+
211+
test.skip('should show an error if actual Shift 2 starts before actual Shift 1 ends', async ({ page }) => {
212+
// Skipped in the legacy `f` shard too — preserved here as `.skip` so the
213+
// file remains a 1:1 structural mirror.
214+
await setTimepickerValue(page, 'start1StartedAt', '08:13');
215+
await setTimepickerValue(page, 'stop1StoppedAt', '12:17');
216+
await setTimepickerValue(page, 'start2StartedAt', '11:29');
217+
await assertInputError(page, 'start2StartedAt-Error', 'Start kan ikke være tidligere end stop for den forrige skift');
218+
});
219+
220+
// --- Positive-path math tests (midnight-wrap with off-grid endpoints) ---
221+
test('should select midnight to some hours', async ({ page }) => {
222+
// 00:00 → 02:24 ⇒ planned 144 min ⇒ planHours = 2.4.
223+
// Actual 00:00 → 02:24 ⇒ start1Id = (0/5)+1 = 1; stop1Id = (144/5)+1 = 29.8;
224+
// actualMin = (29.8 - 1) * 5 = 144 ⇒ actualHours = 2.4.
225+
// todaysFlex = actualHours - planHours = 2.4 - 2.4 = 0.00.
226+
const t = OFFGRID_TIMES_F1M.midnightToHours;
227+
await setTimepickerValue(page, 'plannedStartOfShift1', t.start);
228+
await setTimepickerValue(page, 'plannedEndOfShift1', t.stop);
229+
await setTimepickerValue(page, 'start1StartedAt', t.start);
230+
await setTimepickerValue(page, 'stop1StoppedAt', t.stop);
231+
await expect(page.locator('#planHours')).toHaveValue(OFFGRID_TIMES_F1M.midnightToHoursPlan);
232+
await expect(page.locator('#todaysFlex')).toHaveValue(OFFGRID_TIMES_F1M.zeroFlex);
233+
});
234+
235+
test('should select some hours to midnight', async ({ page }) => {
236+
// 02:24 → 00:00 ⇒ midnight-wrap: planned (1440 - 144) + 0 = 1296 min ⇒ planHours = 21.6.
237+
// Actual 02:24 → 00:00 ⇒ start1Id = (144/5)+1 = 29.8; stop1Id = 289 (isStop && result===0);
238+
// actualMin = (289 - 29.8) * 5 = 1296 ⇒ actualHours = 21.6.
239+
// todaysFlex = 21.6 - 21.6 = 0.00.
240+
const t = OFFGRID_TIMES_F1M.hoursToMidnight;
241+
await setTimepickerValue(page, 'plannedStartOfShift1', t.start);
242+
await setTimepickerValue(page, 'plannedEndOfShift1', t.stop);
243+
await setTimepickerValue(page, 'start1StartedAt', t.start);
244+
await setTimepickerValue(page, 'stop1StoppedAt', t.stop);
245+
await expect(page.locator('#planHours')).toHaveValue(OFFGRID_TIMES_F1M.hoursToMidnightPlan);
246+
await expect(page.locator('#todaysFlex')).toHaveValue(OFFGRID_TIMES_F1M.zeroFlex);
247+
});
248+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Post-migration patch for the f1m variant shard.
2+
--
3+
-- The base seed (`420_eform-angular-time-planning-plugin.sql`) is an old EF
4+
-- baseline dump that pre-dates the `UseOneMinuteIntervals` column on
5+
-- `AssignedSites`. The column is added at runtime by base-package migration
6+
-- `20250226060341_Adding3MoreShifts` (executed by `Database.Migrate()` at
7+
-- plugin startup) with default value 0.
8+
--
9+
-- This patch flips the flag on for every active assigned site so the f1m
10+
-- shard exercises the flag-on rendering / form / picker code paths. The
11+
-- workflow runs this AFTER `Wait for app` (which gates on migrations being
12+
-- complete) and BEFORE the matrix Playwright invocation.
13+
UPDATE AssignedSites SET UseOneMinuteIntervals = 1 WHERE WorkflowState = 'created';

eform-client/playwright/helpers/one-minute-times.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,58 @@ export const OFFGRID_TIMES_E1M = {
104104
shift5End: '11:46',
105105
break: '00:31',
106106
} as const;
107+
108+
/**
109+
* f1m shard variant: validation-pair time literals for the SAVE-FAILURE
110+
* (negative path) tests cloned from `f/dashboard-edit-a.spec.ts` — i.e.
111+
* "stop before start", "same start/stop" and "pause longer than shift"
112+
* checks. Where b1m/c1m/d1m/e1m sweep different quadrants of the clock
113+
* with ascending 5-shift round-trip data, f1m's spec only fills shift 1
114+
* with single-shift pairs (and a midnight-wrap math test). The contract
115+
* that matters for these tests is the INVALID RELATIONSHIP between the
116+
* paired times — not their position on the clock face — so each pair
117+
* here intentionally preserves the same negative-path semantics as the
118+
* legacy `f` shard's `'10:00' / '9:00'`-style values, just shifted to
119+
* off-grid (non-multiple-of-5) minutes so the flag-on `minutesGap=1`
120+
* picker is the only way to land on them.
121+
*
122+
* Math-friendly midnight pair (`midnight*` keys below) lands on a clean
123+
* fractional planHours: `00:00 → 02:24` ⇒ 144 min ⇒ planHours = 2.4 (and
124+
* the mirror `02:24 → 00:00` ⇒ 1296 min ⇒ planHours = 21.6) so the
125+
* spec's `toHaveValue` assertions stay deterministic without float
126+
* pretty-printing.
127+
*
128+
* Invalid-relationship checks (no shift-order constraint here — these
129+
* pairs INTENTIONALLY violate constraints to trigger validators):
130+
* • plannedStopBefore : start > stop
131+
* • plannedSameTime : start === stop (both nonzero)
132+
* • actualStopBefore : start > stop
133+
* • actualPauseTooLong : pause >= (stop - start)
134+
* • actualSameTime : start === stop (both nonzero)
135+
*/
136+
export const OFFGRID_TIMES_F1M = {
137+
// Planned: stop-before-start (10:23 > 09:17).
138+
plannedStopBefore: { start: '10:23', stop: '09:17' },
139+
// Planned: same-start-stop (both 09:43, both nonzero).
140+
plannedSameTime: { start: '09:43', stop: '09:43' },
141+
// Actual: stop-before-start (11:29 > 09:11), pause kept at 00:00.
142+
actualStopBefore: { start: '11:29', stop: '09:11', pause: '00:00' },
143+
// Actual: pause equal to shift duration (boundary case). 10:31 - 08:13 =
144+
// 138 min; pause = 02:18 = 138 min ⇒ `breakMin >= duration` (138 ≥ 138)
145+
// trips `breakTooLong`. Mirrors the legacy `f` shard which uses pause =
146+
// shift-duration exactly (8:00-10:00 / 2:00 ⇒ 120 ≥ 120). The pause
147+
// input has `[max]=getMaxDifference(start,stop)` so the timepicker caps
148+
// selection at the duration; picking AT the max equals the boundary
149+
// and fires the validator. Off-grid: 18 mod 5 = 3 ≠ 0.
150+
actualPauseTooLong: { start: '08:13', stop: '10:31', pause: '02:18' },
151+
// Actual: same-start-stop (both 09:43, both nonzero), pause 00:00.
152+
actualSameTime: { start: '09:43', stop: '09:43', pause: '00:00' },
153+
// Math: midnight → off-grid hour. 00:00 → 02:24 ⇒ 144 min plan ⇒ 2.4 h.
154+
midnightToHours: { start: '00:00', stop: '02:24' },
155+
// Math: off-grid hour → midnight. 02:24 → 00:00 ⇒ 1296 min plan ⇒ 21.6 h.
156+
hoursToMidnight: { start: '02:24', stop: '00:00' },
157+
// Computed expectations for the math tests above.
158+
midnightToHoursPlan: '2.4',
159+
hoursToMidnightPlan: '21.6',
160+
zeroFlex: '0.00',
161+
} as const;

0 commit comments

Comments
 (0)