Skip to content

Commit 5bd8425

Browse files
authored
Merge pull request #1554 from microting/feat/use-one-minute-intervals-phase-5-pr8-i1m
Phase 5 PR 8: i1m playwright variant — UseOneMinuteIntervals=true
2 parents 44d4d13 + e89aef0 commit 5bd8425

7 files changed

Lines changed: 3548 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,q,b1m,c1m,d1m,e1m,f1m,h1m]
91+
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,b1m,c1m,d1m,e1m,f1m,h1m,i1m]
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,f1m,h1m]
84+
test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,b1m,c1m,d1m,e1m,f1m,h1m,i1m]
8585
steps:
8686
- uses: actions/checkout@v3
8787
with:

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

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

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

Lines changed: 518 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { LoginPage } from '../../../Page objects/Login.page';
3+
import { OFFGRID_TIMES_I1M } from '../../../../helpers/one-minute-times';
4+
5+
/**
6+
* i1m variant of `i/dashboard-edit-a.spec.ts` — the "edit time planned in
7+
* last week" single-shift round-trip test, exercised under the
8+
* `UseOneMinuteIntervals = true` code path. The shard's `post-migration.sql`
9+
* flips the flag on every active assigned site, so the legacy in-spec
10+
* setup from `i/` runs against the `minutesGap = 1` timepicker context.
11+
*
12+
* Why "shifts 1 only" shape (deviation from b1m / c1m / d1m / e1m):
13+
*
14+
* The legacy `i/` shard fills ONLY shift 1 (`plannedStartOfShift1` +
15+
* `plannedEndOfShift1`) — no actual times, no shifts 2-5. Earlier 1m
16+
* shards (b1m / c1m / d1m / e1m / g1m) clone the multishift-shape
17+
* pattern by filling all five shifts ascending, but those source specs
18+
* already exercise shifts 3-5 because their seed has the per-site
19+
* `thirdShiftActive / fourthShiftActive / fifthShiftActive` flags set.
20+
* The `i/` source spec doesn't touch shifts 2-5 (the inputs aren't
21+
* even rendered in the dialog by default), so cloning in the same
22+
* shape — single shift 1 — is the correct call. Forcing a five-shift
23+
* block here would time out on `[data-testid="plannedStartOfShift3"]`
24+
* exactly the way an earlier (reverted) g1m attempt did.
25+
*
26+
* Off-grid times: `OFFGRID_TIMES_I1M` defines `07:14` → `15:14` (both
27+
* minute-14, non-multiple of 5) so the flag-on `minutesGap=1` rendering
28+
* is the only way the picker can land on these values. The resulting
29+
* `planHours` is a clean integer `8` (480 min) — matching the legacy
30+
* `i` shard's `planHours='8'` assertion exactly. See helper for math.
31+
*
32+
* Save gating: `await expect(page.locator('#saveButton'))
33+
* .toBeEnabled({ timeout: 10000 });` BEFORE `click()` — never
34+
* `force: true`. The shift validators only fire when start AND stop
35+
* are both non-empty AND form a valid range; with `07:14 → 15:14`
36+
* filled, the saveButton is enabled.
37+
*
38+
* Display assertions stay as `HH:mm` (not `HH:mm:ss`) — the
39+
* `[data-testid="plannedStartOfShift${n}"]` input control reports its
40+
* value as `HH:mm`. This matches the b1m / c1m / d1m / e1m precedent.
41+
*
42+
* `afterEach` cleanup: mirrors the legacy `i/` spec's row-level delete
43+
* dance — open the dialog again and click the delete-icon next to each
44+
* planned shift field that has a value, then save. This keeps the seed
45+
* row clean for the next test (and for adjacent shards that share the
46+
* same baseline data).
47+
*/
48+
49+
async function waitForSpinner(page: Page) {
50+
if (await page.locator('.overlay-spinner').count() > 0) {
51+
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
52+
}
53+
}
54+
55+
/**
56+
* Position-based clock-face picker. Identical helper to the b1m / c1m /
57+
* d1m / e1m / f1m / g1m specs — works uniformly for h=0 (break and
58+
* midnight times) unlike rotateZ-selector strategies that fail on the
59+
* inner-ring `00` position.
60+
*/
61+
async function pickTime(page: Page, timeStr: string) {
62+
const [hourStr, minuteStr] = timeStr.split(':');
63+
const h = parseInt(hourStr, 10);
64+
const m = parseInt(minuteStr, 10);
65+
66+
const cx = 145, cy = 145;
67+
68+
const hourFace = page.locator('.clock-face');
69+
await hourFace.first().waitFor({ state: 'visible', timeout: 5000 });
70+
const hourAngle = (h % 12) * 30;
71+
const hourR = (h === 0 || h > 12) ? 60 : 100;
72+
const hourRad = hourAngle * Math.PI / 180;
73+
await hourFace.first().click({
74+
position: {
75+
x: Math.round(cx + hourR * Math.sin(hourRad)),
76+
y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0),
77+
},
78+
});
79+
80+
await page.waitForTimeout(500);
81+
const minuteFace = page.locator('.clock-face');
82+
await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 });
83+
const minuteAngle = m * 6;
84+
const minuteR = 100;
85+
const minuteRad = minuteAngle * Math.PI / 180;
86+
await minuteFace.first().click({
87+
position: {
88+
x: Math.round(cx + minuteR * Math.sin(minuteRad)),
89+
y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0),
90+
},
91+
});
92+
93+
await page.waitForTimeout(500);
94+
await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click();
95+
// Wait for the timepicker overlay to fully close before the next pick.
96+
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
97+
await page.waitForTimeout(500);
98+
}
99+
100+
/** Open the timepicker for `selector` and pick `timeStr`. */
101+
async function setTimepickerValue(page: Page, selector: string, timeStr: string) {
102+
await page.locator(`[data-testid="${selector}"]`).click();
103+
await pickTime(page, timeStr);
104+
}
105+
106+
test.describe('Dashboard edit values (i1m, flag-on, off-grid single-shift)', () => {
107+
test.beforeEach(async ({ page }) => {
108+
await page.goto('http://localhost:4200');
109+
await new LoginPage(page).login();
110+
111+
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
112+
113+
const indexUpdatePromise = page.waitForResponse(
114+
r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'
115+
);
116+
117+
await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
118+
await indexUpdatePromise;
119+
await waitForSpinner(page);
120+
121+
await page.locator('#workingHoursSite').click();
122+
await page.locator('.ng-option').filter({ hasText: 'ac ad' }).click();
123+
// Wait for site-filter re-index to finish before opening the cell — the
124+
// legacy i spec races a 500ms timeout, but the (reverted) g1m shard
125+
// showed that the site-filter trigger detaches the cell row mid-click
126+
// on flag-on. Wait for the spinner before reaching for #cell0_0.
127+
await waitForSpinner(page);
128+
await page.waitForTimeout(500);
129+
});
130+
131+
test('should edit time planned in last week', async ({ page }) => {
132+
// Open the first day cell (opens a MatDialog).
133+
await page.locator('#cell0_0').click();
134+
// Wait for mtx-grid shift template to render inside the dialog.
135+
await page.locator('[data-testid="plannedStartOfShift1"]').waitFor({ state: 'visible', timeout: 15000 });
136+
137+
// Set planned shift 1 times (off-grid 07:14 → 15:14 ⇒ 480 min ⇒ 8 h).
138+
await setTimepickerValue(page, 'plannedStartOfShift1', OFFGRID_TIMES_I1M.shift1Start);
139+
await expect(page.locator('[data-testid="plannedStartOfShift1"]')).toHaveValue(OFFGRID_TIMES_I1M.shift1Start, { timeout: 5000 });
140+
await setTimepickerValue(page, 'plannedEndOfShift1', OFFGRID_TIMES_I1M.shift1End);
141+
await expect(page.locator('[data-testid="plannedEndOfShift1"]')).toHaveValue(OFFGRID_TIMES_I1M.shift1End, { timeout: 5000 });
142+
143+
// Verify plan hours calculated correctly (15:14 - 07:14 = 8).
144+
await expect(page.locator('#planHours')).toHaveValue(OFFGRID_TIMES_I1M.planHours, { timeout: 5000 });
145+
146+
// Wait for saveButton to be enabled BEFORE clicking — never `force: true`.
147+
await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 });
148+
149+
const savePromise = page.waitForResponse(
150+
r => r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT'
151+
);
152+
await page.locator('#saveButton').click();
153+
await savePromise;
154+
// Wait for dialog to close and table to stabilize.
155+
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
156+
await waitForSpinner(page);
157+
await page.waitForTimeout(2000);
158+
159+
// Reopen the dialog and verify values persisted.
160+
await page.locator('#cell0_0').click();
161+
// Wait for the shift template to fully render in the new dialog.
162+
await page.locator('[data-testid="plannedStartOfShift1"]').waitFor({ state: 'visible', timeout: 15000 });
163+
await page.waitForTimeout(1000);
164+
165+
await expect(page.locator('[data-testid="plannedStartOfShift1"]')).toHaveValue(OFFGRID_TIMES_I1M.shift1Start, { timeout: 10000 });
166+
await expect(page.locator('[data-testid="plannedEndOfShift1"]')).toHaveValue(OFFGRID_TIMES_I1M.shift1End, { timeout: 5000 });
167+
await expect(page.locator('#planHours')).toHaveValue(OFFGRID_TIMES_I1M.planHours, { timeout: 5000 });
168+
});
169+
170+
test.afterEach(async ({ page }) => {
171+
// Close any open dialog.
172+
await page.keyboard.press('Escape');
173+
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
174+
await page.waitForTimeout(1000);
175+
176+
// Open dialog to clean up.
177+
await page.locator('#cell0_0').click();
178+
await page.locator('[data-testid="plannedStartOfShift1"]').waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
179+
await page.waitForTimeout(500);
180+
181+
// Delete planned shift fields if they have values.
182+
for (const selector of ['plannedStartOfShift1', 'plannedEndOfShift1']) {
183+
const deleteBtn = page.locator(`[data-testid="${selector}"]`)
184+
.locator('xpath=ancestor::div[contains(@class,"flex-row")]')
185+
.locator('button mat-icon')
186+
.filter({ hasText: 'delete' });
187+
if (await deleteBtn.count() > 0) {
188+
await deleteBtn.click({ force: true });
189+
await page.waitForTimeout(500);
190+
}
191+
}
192+
193+
// Wait for saveButton to be enabled BEFORE clicking — never `force: true`.
194+
await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 });
195+
const savePromise = page.waitForResponse(
196+
r => r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT'
197+
);
198+
await page.locator('#saveButton').click();
199+
await savePromise;
200+
await page.waitForTimeout(1000);
201+
});
202+
});
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,38 @@ export const OFFGRID_TIMES_G1M = {
198198
shift5End: '19:39',
199199
break: '00:33',
200200
} as const;
201+
202+
/**
203+
* i1m shard variant: single-shift round-trip test ("edit time planned in
204+
* last week") cloned from `i/dashboard-edit-a.spec.ts`. The legacy `i`
205+
* shard fills ONLY `plannedStartOfShift1` + `plannedEndOfShift1` (no
206+
* actual times, no shifts 2-5) — saves, reopens the dialog and asserts
207+
* the values persisted. i1m mirrors that exact "shifts 1 only" shape
208+
* because shifts 2-5 are gated behind per-site flags
209+
* (`secondShiftActive` etc.) that this shard's `post-migration.sql`
210+
* doesn't flip — only `UseOneMinuteIntervals` is flipped.
211+
*
212+
* Off-grid endpoints chosen to produce a clean integer `planHours`
213+
* (480 min = 8 h exactly) so the display assertion stays
214+
* deterministic and matches the legacy `i` shard's `planHours='8'`
215+
* exactly: `15:14 - 07:14 = 8h00m`. Both endpoints have minute = 14
216+
* (non-multiple of 5) so the flag-on `minutesGap=1` rendering is the
217+
* only way the picker can land on them.
218+
*
219+
* Display assertions use `HH:mm` for the timepicker input fields
220+
* (mirrors b1m / c1m / d1m / e1m / g1m precedent — the
221+
* `[data-testid="plannedStartOfShift${n}"]` input control reports
222+
* its value as `HH:mm`, not `HH:mm:ss`).
223+
*
224+
* Clock-quadrant coverage: i1m parks in early-morning-through-mid-
225+
* afternoon (07-15), straddling the outer→inner ring boundary at
226+
* hour 12. Distinct from b1m (08-16), c1m (08-19), d1m (13-23), e1m
227+
* (01-11) and g1m (10-19) so the variant matrix as a whole touches
228+
* every quadrant of the 24-hour clock surface.
229+
*/
230+
export const OFFGRID_TIMES_I1M = {
231+
shift1Start: '07:14',
232+
shift1End: '15:14',
233+
// Computed expectation: (15:14 - 07:14) = 480 min = 8 h exactly.
234+
planHours: '8',
235+
} as const;

0 commit comments

Comments
 (0)