Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { test, expect, Page } from '@playwright/test';
import { LoginPage } from '../../../Page objects/Login.page';

/**
* b shard (flag-off, 5-minute grid): P0 round-trip for the admin pause override
* (Approach C). This is the round-trip that was previously MISSING — earlier
* specs only verified the pause picker accepted a value, never that the edited
* pause (and the resulting netto) SURVIVED save + reopen through the new
* Pause{N}OverrideMinutes channel.
*
* Flow: open a last-week cell, set a known start/stop, set the pause to a known
* value, save, re-open, and assert the pause picker AND #nettoHours reflect the
* edited value. The override is non-destructive server-side, so the displayed
* pause must equal the override the admin typed.
*/

const formatDate = (date: Date): string => {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};

const getMonday = (baseDate: Date): Date => {
const dayOfWeek = baseDate.getDay();
const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(baseDate);
monday.setDate(baseDate.getDate() + diffToMonday);
return monday;
};

const today = new Date();
const lastWeekBase = new Date(today);
lastWeekBase.setDate(today.getDate() - 7);
const lastWeekMonday = getMonday(lastWeekBase);
Comment on lines +17 to +35

async function waitForSpinner(page: Page) {
if (await page.locator('.overlay-spinner').count() > 0) {
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
}
}

// Position-based clock-face picker driver. The brittle
// `[style="transform: rotateZ(...deg) translateX(-50%);"] > span` selector is
// overlapped and times out; the repo standard (and the passing l1m flag-on
// counterpart) computes the hand angle and clicks `.clock-face` at the
// resulting coordinate. This drives the same way for both the start/stop time
// pickers and the pause picker. Math mirrors
// l1m/dashboard-edit-pause-override.spec.ts.
async function pickFiveMinute(page: Page, testid: string, timeStr: string) {
await page.locator(`[data-testid="${testid}"]`).click();
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();
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
await page.waitForTimeout(500);
}

async function openCell(page: Page, cellId: string) {
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
const indexUpdatePromise = 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 page.locator('#backwards').click();
await indexUpdatePromise;
await waitForSpinner(page);
await page.locator(cellId).waitFor({ state: 'visible', timeout: 15000 });
await page.locator(cellId).scrollIntoViewIfNeeded();
await page.locator(cellId).click();
await page.locator('#planHours').waitFor({ state: 'visible', timeout: 15000 });
}

async function saveAndAwait(page: Page) {
const updatePromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT');
const reindexPromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
await page.locator('#saveButton').click();
const updateResponse = await updatePromise;
await reindexPromise;
await waitForSpinner(page);
await page.waitForTimeout(1000);
return updateResponse;
}

test.describe('Dashboard pause-override round-trip (b, flag-off)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:4200');
await new LoginPage(page).login();
});

test('edited pause survives save + reopen via Pause{N}OverrideMinutes', async ({ page }) => {
const cellId = '#cell3_0';

await openCell(page, cellId);

// Known shift-1 actual: 08:00 - 16:00 with a 00:30 pause.
await pickFiveMinute(page, 'start1StartedAt', '08:00');
await expect(page.locator('[data-testid="start1StartedAt"]')).toHaveValue('08:00');
await pickFiveMinute(page, 'stop1StoppedAt', '16:00');
await expect(page.locator('[data-testid="stop1StoppedAt"]')).toHaveValue('16:00');
await pickFiveMinute(page, 'pause1Id', '00:30');
await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue('00:30');

// Netto before save: 8h span - 0.5h pause = 7.50.
await expect(page.locator('#nettoHours')).toHaveValue('7.50');

const res = await saveAndAwait(page);
expect(res.status(), 'PUT must succeed').toBeLessThan(400);

// Re-open and assert the override-derived pause and netto round-tripped.
await page.locator(cellId).scrollIntoViewIfNeeded();
await page.locator(cellId).click();
await expect(page.locator('#planHours')).toBeVisible();
await expect(
page.locator('[data-testid="pause1Id"]'),
'pause must round-trip 00:30 via the override channel',
).toHaveValue('00:30');
await expect(page.locator('#nettoHours')).toHaveValue('7.50');

await page.locator('#cancelButton').click();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { test, expect, Page } from '@playwright/test';
import { LoginPage } from '../../../Page objects/Login.page';

/**
* l1m shard (flag-on, UseOneMinuteIntervals=true): P0 round-trip for the admin
* pause override (Approach C) on a one-minute site.
*
* Pre-Approach-C, the flag-on edit "worked" only because the save path
* DESTROYED the worker's recorded pause sub-slots (ApplyExactMinutePause /
* ClearPauseTimestamps). The override replaces that destructive collapse: the
* admin's typed pause total now rides on Pause{N}OverrideMinutes and the worker
* sub-slots are preserved server-side. This test asserts the admin-typed pause
* survives save + reopen exactly (1-minute precision), proving the override —
* not the legacy Pause{N}Id and not a slot rewrite — is the authoritative
* channel.
*/

async function waitForSpinner(page: Page) {
if (await page.locator('.overlay-spinner').count() > 0) {
await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
}
}

// Position-based 1-minute clock-face picker (mirrors l1m/dashboard-edit-actual-exact).
async function pickTime(page: Page, timeStr: string) {
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();
await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
await page.waitForTimeout(500);
}

async function setTimepickerValue(page: Page, selector: string, timeStr: string) {
await page.locator(`[data-testid="${selector}"]`).click();
await pickTime(page, timeStr);
}

async function openDialogForActiveCell(page: Page) {
await page.goto('http://localhost:4200');
await new LoginPage(page).login();
await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
const indexUpdatePromise = 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 indexUpdatePromise;
await waitForSpinner(page);
await page.locator('#workingHoursSite').click();
await page.locator('.ng-option').filter({ hasText: 'ac ad' }).click();
await page.locator('#cell0_0').click();
await page.locator('#planHours').waitFor({ state: 'visible', timeout: 15000 });
}

async function reopenCell(page: Page) {
await page.locator('#cell0_0').scrollIntoViewIfNeeded();
await page.locator('#cell0_0').click();
await expect(page.locator('#planHours')).toBeVisible();
}

async function clickSaveAndAwaitRoundtrip(page: Page) {
await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 });
const updatePromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT');
const reindexPromise = page.waitForResponse(r =>
r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST');
await page.locator('#saveButton').click();
const updateResponse = await updatePromise;
await reindexPromise;
await waitForSpinner(page);
await page.waitForTimeout(500);
return updateResponse;
}

test.describe('Dashboard pause-override round-trip (l1m, flag-on)', () => {
test('admin-typed pause total round-trips at 1-minute precision', async ({ page }) => {
test.setTimeout(180000);
await openDialogForActiveCell(page);

// Establish a known shift-1 span and set a deliberately off-grid pause
// total (00:23) so the assertion can only pass if the exact override
// round-tripped (not a 5-min-quantized value).
await setTimepickerValue(page, 'start1StartedAt', '08:01');
await setTimepickerValue(page, 'stop1StoppedAt', '16:11');
await setTimepickerValue(page, 'pause1Id', '00:23');
await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue('00:23');

const res = await clickSaveAndAwaitRoundtrip(page);
expect(res.status(), 'PUT must succeed under flag-on with an override pause').toBeLessThan(400);

await reopenCell(page);
await expect(
page.locator('[data-testid="pause1Id"]'),
'pause must round-trip 00:23 exactly via Pause1OverrideMinutes (not 00:20 / 00:25)',
).toHaveValue('00:23');

await page.locator('#cancelButton').click();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@
<button mat-icon-button color="warn" (click)="resetActualTimes((+shiftId - 1) * 3 + 2)">
<mat-icon>delete</mat-icon>
</button>
<button
mat-icon-button
type="button"
color="primary"
[matTooltip]="'Reset pause to recorded' | translate"
[attr.data-testid]="'resetPauseToRecorded' + shiftId"
(click)="resetPauseToRecorded(+shiftId)">
<mat-icon>restore</mat-icon>
</button>
Comment on lines +229 to +237
<ng-container
[ngTemplateOutlet]="trackingButtons"
[ngTemplateOutletContext]="{ key: pauseKey }">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,84 @@ describe('WorkdayEntityDialogComponent', () => {
});
});

describe('Pause override (Approach C) save wiring', () => {
// `mockData` is a single module-level object shared (by reference) across
// every test via MAT_DIALOG_DATA. Tests in this block write the pause
// override fields on `planningPrDayModels`, and the outer `beforeEach`
// never deep-resets that object — so without this local reset a sibling
// test could leave the override/Specified fields dirty and poison the next
// one (the historical order-dependent CI failures). Reset every field this
// block touches to a known-clean baseline before each test so every test is
// self-contained and order-independent.
beforeEach(() => {
const m = component.data.planningPrDayModels as any;
for (let shift = 1; shift <= 5; shift++) {
m[`pause${shift}OverrideMinutes`] = null;
m[`pause${shift}OverrideMinutesSpecified`] = false;
}
m.clearPauseOverrides = false;
});

it('sets pause1OverrideMinutes + Specified when the pause field changes', () => {
// Drive the save-wiring unit directly with an explicit zero baseline so a
// 45-min pause is an unambiguous change. This bypasses the form-group →
// value plumbing in onUpdateWorkDayEntity (which can yield undefined pause
// values under jsdom) and tests applyPauseOverrideForShift's change
// detection deterministically, free of the shared fixture.
const m = component.data.planningPrDayModels as any;
component.ngOnInit();

// current (45) !== loaded (0) → genuine change.
(component as any).loadedPauseMinutes = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
(component as any).applyPauseOverrideForShift(1, '00:45');

expect(m.pause1OverrideMinutesSpecified).toBe(true);
expect(m.pause1OverrideMinutes).toBe(45);
});

it('leaves Specified=false when the pause field is unchanged', () => {
// current === loaded → no override written. Drive the unit directly with
// an explicit baseline so this never depends on the shared model's state
// or on jsdom form-value extraction.
const m = component.data.planningPrDayModels as any;
component.ngOnInit();

(component as any).loadedPauseMinutes = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
// pauseOverrideCleared[1] is false (reset by ngOnInit), current = 0 = loaded.
(component as any).applyPauseOverrideForShift(1, '00:00');

expect(m.pause1OverrideMinutesSpecified).toBe(false);
});

it('clear affordance signals revert-to-recorded (Specified=true, override=null)', () => {
// resetPauseToRecorded marks shift 1 cleared and resets its picker to the
// recorded sum (0 here → null hh:mm). As long as the picker still shows
// that value, applyPauseOverrideForShift must emit Specified=true with a
// null override. Drive it directly with the cleared value to avoid the
// form-extraction path in onUpdateWorkDayEntity.
const m = component.data.planningPrDayModels as any;
component.ngOnInit();

component.resetPauseToRecorded(1);
const clearedMinutes = (component as any).pauseOverrideClearedMinutes[1];
// clearedMinutes is the raw minutes the picker was reset to (null when 0).
const clearedHhmm = component.convertMinutesToTime(clearedMinutes);
(component as any).applyPauseOverrideForShift(1, clearedHhmm);

expect(m.pause1OverrideMinutesSpecified).toBe(true);
expect(m.pause1OverrideMinutes).toBeNull();
});

it('prefers the served override for the displayed pause value', () => {
// Display precedence: a served override projects onto the pause picker.
(component.data.planningPrDayModels as any).pause1OverrideMinutes = 30;

component.ngOnInit();

expect(component.workdayForm.get('actual.shift1.pause')?.value).toBe('00:30');
});
});

describe('Flag Change Handling', () => {
it('should turn off other flags when one is turned on', () => {
component.ngOnInit();
Expand Down
Loading
Loading