mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
925 lines
35 KiB
TypeScript
925 lines
35 KiB
TypeScript
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||
import { test } from '../playwright/fixtures';
|
||
import { expect } from '@playwright/test';
|
||
import type { Page } from '@playwright/test';
|
||
import {
|
||
createProjectViaApi,
|
||
createTaskViaApi,
|
||
createTimeEntryOnDateViaApi,
|
||
createTimeEntryWithTimestampsViaApi,
|
||
getTimeEntriesViaApi,
|
||
updateOrganizationSettingViaApi,
|
||
} from './utils/api';
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Helpers
|
||
// ──────────────────────────────────────────────────
|
||
|
||
async function goToTimesheet(page: Page) {
|
||
await page.addInitScript(() => {
|
||
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
|
||
});
|
||
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
|
||
}
|
||
|
||
function getMonday(d: Date): Date {
|
||
const date = new Date(d);
|
||
const day = date.getUTCDay();
|
||
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
|
||
date.setUTCDate(diff);
|
||
date.setUTCHours(0, 0, 0, 0);
|
||
return date;
|
||
}
|
||
|
||
function getCurrentWeekMonday(): Date {
|
||
return getMonday(new Date());
|
||
}
|
||
|
||
function getLastWeekMonday(): Date {
|
||
const monday = getCurrentWeekMonday();
|
||
monday.setUTCDate(monday.getUTCDate() - 7);
|
||
return monday;
|
||
}
|
||
|
||
function getDayOfWeek(weekStart: Date, dayOffset: number): Date {
|
||
const date = new Date(weekStart);
|
||
date.setUTCDate(date.getUTCDate() + dayOffset);
|
||
return date;
|
||
}
|
||
|
||
async function waitForTimesheetLoad(page: Page) {
|
||
await page.waitForURL(/\/timesheet(?:$|\?)/);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
|
||
|
||
const timezoneMismatchModal = page
|
||
.getByRole('dialog')
|
||
.filter({ hasText: 'Timezone mismatch detected' });
|
||
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
|
||
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
|
||
await expect(timezoneMismatchModal).not.toBeVisible();
|
||
}
|
||
}
|
||
|
||
function addRowButton(page: Page) {
|
||
return page.getByRole('button', { name: /Add row/i }).first();
|
||
}
|
||
|
||
async function chooseRowIdentity(page: Page, optionName: string) {
|
||
await addRowButton(page).click();
|
||
|
||
const dialog = page.getByRole('dialog', { name: /Add row/i });
|
||
const dialogVisible = await dialog
|
||
.waitFor({ state: 'visible', timeout: 1000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
|
||
if (dialogVisible) {
|
||
await dialog.getByRole('option', { name: optionName }).click();
|
||
return;
|
||
}
|
||
|
||
if (optionName === 'No Project') return;
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
await row.getByText('No Project').click();
|
||
await page.getByText(optionName).click();
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Navigation & Page Load
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('timesheet renders empty with add row + copy last week actions', async ({ page }) => {
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
|
||
await expect(addRowButton(page)).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Copy last week/i })).toBeVisible();
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Display Existing Time Entries
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('timesheet displays existing time entries grouped by project', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const tuesday = getDayOfWeek(monday, 1);
|
||
const wednesday = getDayOfWeek(monday, 2);
|
||
|
||
const projectA = await createProjectViaApi(ctx, { name: 'Project Alpha' });
|
||
const projectB = await createProjectViaApi(ctx, { name: 'Project Beta' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: projectA.id,
|
||
});
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: wednesday,
|
||
duration: '1h',
|
||
projectId: projectA.id,
|
||
});
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: tuesday,
|
||
duration: '3h',
|
||
projectId: projectB.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(2);
|
||
|
||
// Check that the grand total is shown
|
||
await expect(page.getByTestId('timesheet_grand_total')).toBeVisible();
|
||
});
|
||
|
||
test('timesheet groups entries by project and task combination', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
|
||
const project = await createProjectViaApi(ctx, { name: 'Task Project' });
|
||
const taskA = await createTaskViaApi(ctx, { name: 'Task A', project_id: project.id });
|
||
const taskB = await createTaskViaApi(ctx, { name: 'Task B', project_id: project.id });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
taskId: taskA.id,
|
||
});
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
taskId: taskB.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(2);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Enter Duration in Cell
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('entering duration in empty cell creates a time entry', async ({ page, ctx }) => {
|
||
await createProjectViaApi(ctx, { name: 'Duration Test' });
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
await chooseRowIdentity(page, 'Duration Test');
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
|
||
// Click the first day cell and enter duration
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayCell = cells.first();
|
||
const mondayInput = mondayCell.locator('input');
|
||
|
||
await mondayInput.click();
|
||
await mondayInput.fill('2');
|
||
|
||
// Submit and wait for create response
|
||
const [response] = await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
mondayInput.press('Enter'),
|
||
]);
|
||
|
||
expect(response.status()).toBe(201);
|
||
|
||
// Verify the cell shows the duration
|
||
await expect(mondayInput).not.toHaveValue('');
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Edit Duration (Increase)
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('increasing duration in cell extends the last time entry', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Increase Test' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
|
||
// Click and change to 3 hours
|
||
await mondayInput.click();
|
||
await mondayInput.fill('3');
|
||
|
||
const [response] = await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'PUT' &&
|
||
resp.status() === 200
|
||
),
|
||
mondayInput.press('Enter'),
|
||
]);
|
||
|
||
expect(response.status()).toBe(200);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Edit Duration (Decrease)
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('decreasing duration in cell shortens the last time entry', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Decrease Test' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '3h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
|
||
await mondayInput.click();
|
||
await mondayInput.fill('1');
|
||
|
||
const [response] = await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'PUT' &&
|
||
resp.status() === 200
|
||
),
|
||
mondayInput.press('Enter'),
|
||
]);
|
||
|
||
expect(response.status()).toBe(200);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Clear Cell
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('clearing a cell deletes all time entries for that project+day', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Clear Test' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
|
||
await mondayInput.click();
|
||
await mondayInput.fill('0');
|
||
|
||
const [response] = await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'DELETE' &&
|
||
resp.status() === 200
|
||
),
|
||
mondayInput.press('Enter'),
|
||
]);
|
||
|
||
expect(response.status()).toBe(200);
|
||
});
|
||
|
||
test('Escape during cell edit reverts the displayed value without an API call', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Escape Cancel Test' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
|
||
// Capture the formatted display value before editing.
|
||
const originalValue = await mondayInput.inputValue();
|
||
expect(originalValue).toMatch(/2/);
|
||
|
||
let mutationFired = false;
|
||
page.on('request', (req) => {
|
||
if (req.url().includes('/time-entries') && req.method() !== 'GET') {
|
||
mutationFired = true;
|
||
}
|
||
});
|
||
|
||
await mondayInput.click();
|
||
await mondayInput.fill('5');
|
||
await mondayInput.press('Escape');
|
||
|
||
// The Escape handler reverts the displayed value synchronously, so
|
||
// once this assertion passes we know the handler ran. Any mutation
|
||
// request would have been queued by then.
|
||
await expect(mondayInput).toHaveValue(originalValue);
|
||
expect(mutationFired).toBe(false);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Week Navigation
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('navigating to previous week shows entries from that week', async ({ page, ctx }) => {
|
||
const lastMonday = getLastWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Last Week Project' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: lastMonday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Current week should have no entries
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
|
||
|
||
// Go to previous week — the row-count assertion below auto-retries
|
||
// until the new week's data arrives.
|
||
await page.getByTestId('timesheet_prev_week').click();
|
||
|
||
// Should now see the entry
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
});
|
||
|
||
test('can navigate forward and return to current week', async ({ page }) => {
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Should show "This week"
|
||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||
|
||
// Go to next week — the text assertions below auto-retry until the
|
||
// header label flips.
|
||
await page.getByTestId('timesheet_next_week').click();
|
||
|
||
// Should no longer show "This week"
|
||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
||
|
||
// Go back to this week
|
||
await page.getByTestId('timesheet_week_display').click();
|
||
|
||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Copy Last Week
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('copy last week adds project rows from previous week without hours', async ({ page, ctx }) => {
|
||
const lastMonday = getLastWeekMonday();
|
||
const lastWednesday = getDayOfWeek(lastMonday, 2);
|
||
|
||
const projectA = await createProjectViaApi(ctx, { name: 'Copy Project A' });
|
||
const projectB = await createProjectViaApi(ctx, { name: 'Copy Project B' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: lastMonday,
|
||
duration: '2h',
|
||
projectId: projectA.id,
|
||
});
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: lastWednesday,
|
||
duration: '3h',
|
||
projectId: projectB.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Current week should have no populated rows yet.
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
|
||
|
||
// Open copy last week dropdown and click "Copy rows only"
|
||
await page.getByRole('button', { name: /Copy last week/i }).click();
|
||
await page.getByText('Copy rows only').click();
|
||
|
||
// Should now show 2 rows (one per project)
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(2);
|
||
|
||
// All row totals should be 0
|
||
const rowTotals = page.locator('[data-testid="timesheet_row_total"]');
|
||
const count = await rowTotals.count();
|
||
for (let i = 0; i < count; i++) {
|
||
await expect(rowTotals.nth(i)).toContainText('-');
|
||
}
|
||
});
|
||
|
||
test('copy last week does not duplicate rows that already exist', async ({ page, ctx }) => {
|
||
const lastMonday = getLastWeekMonday();
|
||
const thisMonday = getCurrentWeekMonday();
|
||
const thisTuesday = getDayOfWeek(thisMonday, 1);
|
||
|
||
const project = await createProjectViaApi(ctx, { name: 'No Dup Project' });
|
||
|
||
// Create entry for last week
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: lastMonday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
// Create entry for current week
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: thisTuesday,
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Should have 1 row (from current week entry)
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
|
||
// Open copy last week dropdown and click "Copy rows only"
|
||
await page.getByRole('button', { name: /Copy last week/i }).click();
|
||
await page.getByText('Copy rows only').click();
|
||
|
||
// Should still have only 1 row (not duplicated)
|
||
await expect(rows).toHaveCount(1);
|
||
});
|
||
|
||
test('copy last week with time entries creates rows and entries', async ({ page, ctx }) => {
|
||
const lastMonday = getLastWeekMonday();
|
||
|
||
const project = await createProjectViaApi(ctx, { name: 'Copy Time Project' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: lastMonday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Current week should have no populated rows yet.
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
|
||
|
||
// Open copy last week dropdown and click "Copy rows and time entries"
|
||
await page.getByRole('button', { name: /Copy last week/i }).click();
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
page.getByText('Copy rows and time entries').click(),
|
||
]);
|
||
|
||
// Should now show 1 row with time entries
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
|
||
// Row total should not be 0 (entries were copied)
|
||
const rowTotal = page.locator('[data-testid="timesheet_row_total"]').first();
|
||
await expect(rowTotal).not.toContainText('0 h');
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Row Removal
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('can remove an empty project row without confirmation', async ({ page, ctx }) => {
|
||
const project = await createProjectViaApi(ctx, { name: 'Empty Remove Project' });
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
await chooseRowIdentity(page, project.name);
|
||
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
|
||
// Hover the row to reveal the X button, then click it
|
||
await rows.first().hover();
|
||
await rows.first().getByRole('button', { name: 'Remove row' }).click();
|
||
|
||
// Row should be removed immediately (no dialog)
|
||
await expect(rows).toHaveCount(0);
|
||
});
|
||
|
||
test('removing a row with entries shows confirmation dialog and deletes entries', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Delete Row Project' });
|
||
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
|
||
// Hover and click X
|
||
await rows.first().hover();
|
||
await rows.first().getByRole('button', { name: 'Remove row' }).click();
|
||
|
||
// Confirmation dialog should appear
|
||
await expect(page.getByRole('alertdialog')).toBeVisible();
|
||
await expect(page.getByText('Remove timesheet row?')).toBeVisible();
|
||
|
||
// Click Delete
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'DELETE' &&
|
||
resp.status() === 200
|
||
),
|
||
page
|
||
.getByRole('alertdialog')
|
||
.getByRole('button', { name: /Delete/i })
|
||
.click(),
|
||
]);
|
||
|
||
// Row should be gone
|
||
await expect(rows).toHaveCount(0);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Multiple Entries Same Cell
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('cell correctly sums multiple entries for same project+day', async ({ page, ctx }) => {
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'Sum Test' });
|
||
|
||
// Create 2 entries for the same project on Monday
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
description: 'Entry 1',
|
||
});
|
||
await createTimeEntryOnDateViaApi(ctx, {
|
||
date: monday,
|
||
duration: '2h',
|
||
projectId: project.id,
|
||
description: 'Entry 2',
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
// Should be 1 row (both entries grouped)
|
||
const rows = page.locator('[data-testid="timesheet_row"]');
|
||
await expect(rows).toHaveCount(1);
|
||
|
||
// The Monday cell should show 3h total
|
||
const cells = rows.first().locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
// The value should contain "3" (for 3h in some format)
|
||
await expect(mondayInput).toHaveValue(/3/);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Duration Input Formats
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('cell accepts various duration input formats', async ({ page, ctx }) => {
|
||
await createProjectViaApi(ctx, { name: 'Format Test' });
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
|
||
await chooseRowIdentity(page, 'Format Test');
|
||
|
||
const row = page.locator('[data-testid="timesheet_row"]').first();
|
||
|
||
// Test entering "1.5" (should be 1h 30min)
|
||
const cells = row.locator('[data-testid="timesheet_cell"]');
|
||
const mondayInput = cells.first().locator('input');
|
||
|
||
await mondayInput.click();
|
||
await mondayInput.fill('1.5');
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
mondayInput.press('Enter'),
|
||
]);
|
||
|
||
// 1.5 hours = 1h 30min
|
||
await expect(mondayInput).toHaveValue('1h 30min');
|
||
});
|
||
|
||
test('test that adding a timesheet break to a full day splits the work entry via the placement modal', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// A single work entry filling the day leaves no gap for a break, so the placement
|
||
// modal must offer to split it (the only entry) and drop the break in the middle.
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T09:00:00Z`,
|
||
end: `${day}T17:00:00Z`,
|
||
description: 'Split me',
|
||
});
|
||
|
||
await goToTimesheet(page);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
|
||
// The break row is always present — enter a 30m break on Monday
|
||
const breakRow = page
|
||
.locator('[data-testid="timesheet_row"]')
|
||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||
await breakCell.click();
|
||
await breakCell.fill('0.5');
|
||
await breakCell.press('Enter');
|
||
|
||
// The placement modal opens with the split preview
|
||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
async (resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201 &&
|
||
(await resp.json()).data.type === 'break'
|
||
),
|
||
page.getByRole('button', { name: 'Add break' }).click(),
|
||
]);
|
||
|
||
// The day now has two work halves and one break, none overlapping
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const dayEntries = entries
|
||
.filter((e) => e.start.startsWith(day))
|
||
.sort((a, b) => a.start.localeCompare(b.start));
|
||
expect(dayEntries).toHaveLength(3);
|
||
expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']);
|
||
// The break sits flush between the two halves
|
||
expect(dayEntries[0].end).toBe(dayEntries[1].start);
|
||
expect(dayEntries[1].end).toBe(dayEntries[2].start);
|
||
});
|
||
|
||
test('test that adding a break into an oversized gap places it without moving other entries', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// 09-12 and 15-17 leave a 3h gap — wider than the placement tolerance allows,
|
||
// but easily big enough to hold the break. Such a gap is deliberate (the app
|
||
// itself never creates one), so the break goes flush after the morning entry
|
||
// and nothing else moves — no placement modal.
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T09:00:00Z`,
|
||
end: `${day}T12:00:00Z`,
|
||
description: 'Morning',
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T15:00:00Z`,
|
||
end: `${day}T17:00:00Z`,
|
||
description: 'Afternoon',
|
||
});
|
||
|
||
await goToTimesheet(page);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
|
||
const breakRow = page
|
||
.locator('[data-testid="timesheet_row"]')
|
||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||
await breakCell.click();
|
||
await breakCell.fill('0.5');
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
async (resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201 &&
|
||
(await resp.json()).data.type === 'break'
|
||
),
|
||
breakCell.press('Enter'),
|
||
]);
|
||
|
||
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const dayEntries = entries
|
||
.filter((e) => e.start.startsWith(day))
|
||
.sort((a, b) => a.start.localeCompare(b.start));
|
||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||
['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`],
|
||
['work', `${day}T15:00:00Z`, `${day}T17:00:00Z`],
|
||
]);
|
||
});
|
||
|
||
test('test that the placement modal warns when the chosen time would leave the break misaligned', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Back-to-back 09-12 and 12-17 leave no gap, so the placement modal opens.
|
||
// The suggested slot (flush at 12:00) is aligned — no warning. Moving the
|
||
// break to 07:00, before any work, keeps the plan feasible but the result
|
||
// would immediately carry the misaligned hint, so the modal warns upfront.
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T09:00:00Z`,
|
||
end: `${day}T12:00:00Z`,
|
||
description: 'Morning',
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T12:00:00Z`,
|
||
end: `${day}T17:00:00Z`,
|
||
description: 'Afternoon',
|
||
});
|
||
|
||
await goToTimesheet(page);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
|
||
const breakRow = page
|
||
.locator('[data-testid="timesheet_row"]')
|
||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||
await breakCell.click();
|
||
await breakCell.fill('0.5');
|
||
await breakCell.press('Enter');
|
||
|
||
// Default suggestion sits flush between work → no warning
|
||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||
await expect(page.getByTestId('break_placement_misaligned_warning')).not.toBeVisible();
|
||
|
||
// Move the break to 07:00-07:30, before all work
|
||
const modal = page.getByRole('dialog');
|
||
const startTimeInput = modal.getByTestId('time_picker_input').first();
|
||
await startTimeInput.fill('07:00');
|
||
await startTimeInput.press('Tab');
|
||
const endTimeInput = modal.getByTestId('time_picker_input').nth(1);
|
||
await endTimeInput.fill('07:30');
|
||
await endTimeInput.press('Tab');
|
||
|
||
// Feasible (nothing has to move), but flagged as misaligned beforehand
|
||
await expect(page.getByTestId('break_placement_misaligned_warning')).toBeVisible();
|
||
await expect(page.getByTestId('break_placement_summary')).toContainText(
|
||
'No entries need to move.'
|
||
);
|
||
|
||
// The warning is non-blocking: the break can still be added as chosen
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
async (resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201 &&
|
||
(await resp.json()).data.type === 'break'
|
||
),
|
||
page.getByRole('button', { name: 'Add break' }).click(),
|
||
]);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const dayEntries = entries
|
||
.filter((e) => e.start.startsWith(day))
|
||
.sort((a, b) => a.start.localeCompare(b.start));
|
||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||
['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`],
|
||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
|
||
]);
|
||
// ...and the timesheet now shows the misaligned-break hint for that day
|
||
await expect(
|
||
page.getByRole('button', { name: 'does not align with your work entries' })
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Work ends at 10:00 and the break starts hours later with no work after it,
|
||
// so it is misplaced and its day header should carry the warning hint.
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T09:00:00Z`,
|
||
end: `${day}T10:00:00Z`,
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T14:00:00Z`,
|
||
end: `${day}T14:30:00Z`,
|
||
type: 'break',
|
||
});
|
||
|
||
await goToTimesheet(page);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
|
||
// Exactly one warning, sitting in Monday's day header
|
||
const hint = page.getByRole('button', {
|
||
name: 'does not align with your work entries',
|
||
});
|
||
await expect(hint).toHaveCount(1);
|
||
await expect(
|
||
page.getByTestId('timesheet_day_header').first().getByRole('button', {
|
||
name: 'does not align with your work entries',
|
||
})
|
||
).toBeVisible();
|
||
|
||
// The hint links to the calendar on the affected date
|
||
await hint.click();
|
||
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
|
||
'href',
|
||
`/calendar?date=${day}`
|
||
);
|
||
});
|
||
|
||
test('test that editing a timesheet break re-places it as one entry instead of fragmenting it', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Two work entries with a 1h gap, and a 30m break created directly inside it (12:15–12:45).
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T09:00:00Z`,
|
||
end: `${day}T12:00:00Z`,
|
||
description: 'Work',
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T13:00:00Z`,
|
||
end: `${day}T17:00:00Z`,
|
||
description: 'Work',
|
||
});
|
||
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${day}T12:15:00Z`,
|
||
end: `${day}T12:45:00Z`,
|
||
type: 'break',
|
||
});
|
||
|
||
await goToTimesheet(page);
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
const breakRow = page
|
||
.locator('[data-testid="timesheet_row"]')
|
||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||
await breakCell.click();
|
||
await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored
|
||
await Promise.all([
|
||
// A break that still fits its gap is re-placed in place (PUT on the same entry),
|
||
// not deleted and recreated — that's what keeps it a single entry.
|
||
page.waitForResponse(
|
||
async (resp) =>
|
||
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
|
||
resp.request().method() === 'PUT' &&
|
||
resp.status() === 200 &&
|
||
(await resp.json()).data.type === 'break'
|
||
),
|
||
breakCell.press('Enter'),
|
||
]);
|
||
|
||
// Still exactly one break on the day (not fragmented). It stays anchored at its current
|
||
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
|
||
const after = await getTimeEntriesViaApi(ctx);
|
||
const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break');
|
||
expect(breaks).toHaveLength(1);
|
||
expect(breaks[0].duration).toBe(2700);
|
||
expect(breaks[0].start).toBe(`${day}T12:15:00Z`);
|
||
expect(breaks[0].end).toBe(`${day}T13:00:00Z`);
|
||
});
|