|
| 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 | +}); |
0 commit comments