mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-11 07:13:17 +01:00
The tests used today and tomorrow (or yesterday) and skipped when those fell outside the visible week. The Saturday skips were also wrong for the Monday week start e2e users get, so the tests failed whenever CI ran on a Sunday. Using Wednesday and Thursday of the current week keeps both days visible whichever day the suite runs, so the day-of-week skips are gone.
2816 lines
113 KiB
TypeScript
2816 lines
113 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 {
|
||
createBillableProjectViaApi,
|
||
createProjectViaApi,
|
||
createBareTimeEntryViaApi,
|
||
createTimeEntryViaApi,
|
||
createRunningTimeEntryViaApi,
|
||
createTimeEntryWithTimestampsViaApi,
|
||
createRunningTimeEntryWithStartViaApi,
|
||
createTaskViaApi,
|
||
createProjectWithClientViaApi,
|
||
updateUserProfileViaApi,
|
||
updateOrganizationSettingViaApi,
|
||
} from './utils/api';
|
||
|
||
async function goToCalendar(page: Page) {
|
||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||
}
|
||
|
||
async function scrollCalendarToTime(page: Page, time: string) {
|
||
await page.evaluate((t) => {
|
||
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
|
||
if (slot) slot.scrollIntoView({ block: 'start' });
|
||
}, time);
|
||
await page.waitForTimeout(300);
|
||
}
|
||
|
||
async function getSlotHeight(page: Page): Promise<number> {
|
||
return await page.evaluate(() => {
|
||
const slots = document.querySelectorAll('.fc-timegrid-slot-lane');
|
||
for (const slot of slots) {
|
||
const h = slot.getBoundingClientRect().height;
|
||
if (h > 0) return h;
|
||
}
|
||
return 20;
|
||
});
|
||
}
|
||
|
||
async function openContextMenu(page: Page, description: string) {
|
||
const event = page.locator('.fc-event').filter({ hasText: description }).first();
|
||
await expect(event).toBeVisible();
|
||
await event.click({ button: 'right' });
|
||
await expect(page.getByRole('menu')).toBeVisible();
|
||
}
|
||
|
||
function todayAt(hour: number, minute: number = 0): string {
|
||
const now = new Date();
|
||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
|
||
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
}
|
||
|
||
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
|
||
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
|
||
// whichever day the suite runs on.
|
||
function wednesdayAt(hour: number, minute: number = 0): Date {
|
||
const now = new Date();
|
||
const daysSinceMonday = (now.getDay() + 6) % 7;
|
||
return new Date(
|
||
now.getFullYear(),
|
||
now.getMonth(),
|
||
now.getDate() - daysSinceMonday + 2,
|
||
hour,
|
||
minute,
|
||
0,
|
||
0
|
||
);
|
||
}
|
||
|
||
function thursdayAt(hour: number, minute: number = 0): Date {
|
||
const d = wednesdayAt(hour, minute);
|
||
d.setDate(d.getDate() + 1);
|
||
return d;
|
||
}
|
||
|
||
function toApiTimestamp(date: Date): string {
|
||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
}
|
||
|
||
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
|
||
function toDateStr(date: Date): string {
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${date.getFullYear()}-${month}-${day}`;
|
||
}
|
||
|
||
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
|
||
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
|
||
await expect(header).toBeVisible();
|
||
const box = await header.boundingBox();
|
||
return box!.x + box!.width / 2;
|
||
}
|
||
|
||
/**
|
||
* These tests verify that changing the project on a time entry via the calendar
|
||
* updates the billable status to match the new project's is_billable setting.
|
||
*
|
||
* Issue: https://github.com/solidtime-io/solidtime/issues/981
|
||
*/
|
||
|
||
test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000);
|
||
|
||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||
await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Click on the time entry event in the calendar
|
||
await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// Verify initially non-billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||
).toBeVisible();
|
||
|
||
// Select the billable project
|
||
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||
await page.getByRole('option', { name: billableProjectName }).click();
|
||
|
||
// Verify the billable dropdown updated to Billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||
).toBeVisible();
|
||
|
||
// Save and verify
|
||
const [updateResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||
]);
|
||
const responseBody = await updateResponse.json();
|
||
expect(responseBody.data.billable).toBe(true);
|
||
});
|
||
|
||
test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||
const nonBillableProjectName =
|
||
'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||
|
||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||
await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Click on the time entry event in the calendar
|
||
await page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Test billable cal reverse' })
|
||
.first()
|
||
.click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// First assign the billable project
|
||
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||
await page.getByRole('option', { name: billableProjectName }).click();
|
||
|
||
// Verify billable status flipped to Billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||
).toBeVisible();
|
||
|
||
// Now switch to the non-billable project
|
||
await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click();
|
||
await page.getByRole('option', { name: nonBillableProjectName }).click();
|
||
|
||
// Verify billable status reverted to Non-Billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||
).toBeVisible();
|
||
|
||
// Save and verify
|
||
const [updateResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||
]);
|
||
const responseBody = await updateResponse.json();
|
||
expect(responseBody.data.billable).toBe(false);
|
||
});
|
||
|
||
test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const billableProjectName =
|
||
'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000);
|
||
|
||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||
await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Click on the time entry event in the calendar
|
||
await page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Test cal persist override' })
|
||
.first()
|
||
.click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// Assign the billable project
|
||
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||
await page.getByRole('option', { name: billableProjectName }).click();
|
||
|
||
// Verify it auto-set to Billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||
).toBeVisible();
|
||
|
||
// Now manually override billable to Non-Billable via the dropdown
|
||
await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||
await page.getByRole('option', { name: 'Non Billable' }).click();
|
||
|
||
// Verify it shows Non-Billable now
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||
).toBeVisible();
|
||
|
||
// Save
|
||
const [firstSaveResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||
]);
|
||
const firstBody = await firstSaveResponse.json();
|
||
expect(firstBody.data.billable).toBe(false);
|
||
|
||
// Re-open the edit modal from the calendar — the project_id watcher should NOT override billable
|
||
await page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Test cal persist override' })
|
||
.first()
|
||
.click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// The billable dropdown should still show Non-Billable
|
||
await expect(
|
||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||
).toBeVisible();
|
||
|
||
// Save without changes and verify the response still has billable=false
|
||
const [updateResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||
]);
|
||
const responseBody = await updateResponse.json();
|
||
expect(responseBody.data.billable).toBe(false);
|
||
});
|
||
|
||
test('test that calendar page loads and displays time entries', async ({ page, ctx }) => {
|
||
await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Calendar container should be visible
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
// The time entry should appear as a calendar event
|
||
await expect(
|
||
page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first()
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('test that calendar navigation buttons work', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
// Click the "next" button to navigate forward
|
||
await page.getByRole('button', { name: 'Next' }).click();
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
// Click the "prev" button to navigate back
|
||
await page.getByRole('button', { name: 'Previous' }).click();
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
// Navigate forward first, then click today
|
||
await page.getByRole('button', { name: 'Next' }).click();
|
||
await page.getByRole('button', { name: 'today' }).click();
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
});
|
||
|
||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||
const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||
const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, originalDescription, '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Click on the time entry event
|
||
await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// Update the description (edit modal uses placeholder, not data-testid)
|
||
const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?');
|
||
await descriptionInput.fill(updatedDescription);
|
||
|
||
// Save and verify
|
||
const [editResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||
]);
|
||
const editBody = await editResponse.json();
|
||
expect(editBody.data.description).toBe(updatedDescription);
|
||
|
||
// Verify the updated description is shown in the calendar UI
|
||
await expect(
|
||
page.locator('.fc-event').filter({ hasText: updatedDescription }).first()
|
||
).toBeVisible();
|
||
// Verify the old description is no longer shown
|
||
await expect(
|
||
page.locator('.fc-event').filter({ hasText: originalDescription })
|
||
).not.toBeVisible();
|
||
});
|
||
|
||
test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => {
|
||
const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||
|
||
await goToCalendar(page);
|
||
|
||
// Click on the time entry event
|
||
await page.locator('.fc-event').filter({ hasText: description }).first().click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// Click the delete button
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'DELETE' &&
|
||
response.status() === 204
|
||
),
|
||
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
|
||
]);
|
||
|
||
// Verify the event is removed from the calendar
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||
});
|
||
|
||
// =============================================
|
||
// Context Menu Tests
|
||
// =============================================
|
||
|
||
test('test that context menu edit opens the edit modal', async ({ page, ctx }) => {
|
||
const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||
|
||
await goToCalendar(page);
|
||
await openContextMenu(page, description);
|
||
|
||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue(
|
||
description
|
||
);
|
||
});
|
||
|
||
test('test that context menu duplicate preserves project and billable status', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000);
|
||
const project = await createProjectViaApi(ctx, {
|
||
name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000),
|
||
is_billable: true,
|
||
});
|
||
await createTimeEntryViaApi(ctx, {
|
||
description,
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
billable: true,
|
||
});
|
||
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1);
|
||
await openContextMenu(page, description);
|
||
|
||
const [createResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries') &&
|
||
response.request().method() === 'POST' &&
|
||
response.status() === 201
|
||
),
|
||
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
|
||
]);
|
||
|
||
const body = await createResponse.json();
|
||
expect(body.data.description).toBe(description);
|
||
expect(body.data.project_id).toBe(project.id);
|
||
expect(body.data.billable).toBe(true);
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2);
|
||
});
|
||
|
||
test('test that context menu delete removes the time entry', async ({ page, ctx }) => {
|
||
const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||
|
||
await goToCalendar(page);
|
||
await openContextMenu(page, description);
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'DELETE' &&
|
||
response.status() === 204
|
||
),
|
||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||
]);
|
||
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||
});
|
||
|
||
test('test that context menu split divides time entry into two', async ({ page, ctx }) => {
|
||
const description = 'Context split test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '2h');
|
||
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1);
|
||
await openContextMenu(page, description);
|
||
|
||
const [updateResponse, createResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries') &&
|
||
response.request().method() === 'POST' &&
|
||
response.status() === 201
|
||
),
|
||
page.getByRole('menuitem', { name: 'Split' }).click(),
|
||
]);
|
||
|
||
const updateBody = await updateResponse.json();
|
||
const createBody = await createResponse.json();
|
||
expect(updateBody.data.end).toBe(createBody.data.start);
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2);
|
||
});
|
||
|
||
test('test that context menu create time entry opens the create modal', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
const slotLane = page.locator('.fc-timegrid-slot-lane').first();
|
||
await expect(slotLane).toBeVisible();
|
||
await slotLane.click({ button: 'right' });
|
||
|
||
await expect(page.getByRole('menu')).toBeVisible();
|
||
await page.getByRole('menuitem', { name: 'Create Time Entry' }).click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
});
|
||
|
||
test('test that context menu for running entry shows stop and discard options', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const description = 'Running ctx menu test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createRunningTimeEntryViaApi(ctx, description);
|
||
|
||
await goToCalendar(page);
|
||
await openContextMenu(page, description);
|
||
|
||
// Running entry should show Stop and Discard, not Edit/Duplicate/Split/Delete
|
||
await expect(page.getByRole('menuitem', { name: 'Stop' })).toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Discard' })).toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Split' })).not.toBeVisible();
|
||
});
|
||
|
||
test('test that context menu stop on running entry sets end time', async ({ page, ctx }) => {
|
||
const description = 'Running stop test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createRunningTimeEntryViaApi(ctx, description);
|
||
|
||
await goToCalendar(page);
|
||
await openContextMenu(page, description);
|
||
|
||
const [updateResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'PUT' &&
|
||
response.status() === 200
|
||
),
|
||
page.getByRole('menuitem', { name: 'Stop' }).click(),
|
||
]);
|
||
|
||
const body = await updateResponse.json();
|
||
expect(body.data.end).not.toBeNull();
|
||
expect(body.data.description).toBe(description);
|
||
});
|
||
|
||
test('test that context menu discard on running entry deletes it', async ({ page, ctx }) => {
|
||
const description = 'Running discard test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createRunningTimeEntryViaApi(ctx, description);
|
||
|
||
await goToCalendar(page);
|
||
await openContextMenu(page, description);
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries/') &&
|
||
response.request().method() === 'DELETE' &&
|
||
response.status() === 204
|
||
),
|
||
page.getByRole('menuitem', { name: 'Discard' }).click(),
|
||
]);
|
||
|
||
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||
});
|
||
|
||
// =============================================
|
||
// Employee Permission Tests
|
||
// =============================================
|
||
|
||
test.describe('Employee Calendar Isolation', () => {
|
||
test('employee can only see their own time entries on the calendar', async ({
|
||
ctx,
|
||
employee,
|
||
}) => {
|
||
// Owner creates a time entry for today
|
||
const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
|
||
|
||
// Create a time entry for the employee for today
|
||
const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000);
|
||
await createTimeEntryViaApi(
|
||
{ ...ctx, memberId: employee.memberId },
|
||
{ description: employeeDescription, duration: '30min' }
|
||
);
|
||
|
||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||
await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||
|
||
// Employee's event IS visible
|
||
await expect(
|
||
employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first()
|
||
).toBeVisible({ timeout: 10000 });
|
||
|
||
// Owner's event is NOT visible
|
||
await expect(
|
||
employee.page.locator('.fc-event').filter({ hasText: ownerDescription })
|
||
).not.toBeVisible();
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 1: Event Rendering & Display
|
||
// =============================================
|
||
|
||
test.describe('Event Rendering & Display', () => {
|
||
test('event shows description, project name, and duration', async ({ page, ctx }) => {
|
||
const projectName = 'Render Project ' + Math.floor(Math.random() * 10000);
|
||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||
await createTimeEntryViaApi(ctx, {
|
||
description: 'Render test entry',
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
});
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Render test entry' }).first();
|
||
await expect(event).toBeVisible();
|
||
await expect(event).toContainText(projectName);
|
||
await expect(event).toContainText('1h 00min');
|
||
});
|
||
|
||
test('event shows task and client name', async ({ page, ctx }) => {
|
||
const clientName = 'Render Client ' + Math.floor(Math.random() * 10000);
|
||
const projectName = 'Render Task Project ' + Math.floor(Math.random() * 10000);
|
||
const taskName = 'Render Task ' + Math.floor(Math.random() * 10000);
|
||
const { project } = await createProjectWithClientViaApi(ctx, projectName, clientName);
|
||
const task = await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
||
await createTimeEntryViaApi(ctx, {
|
||
description: 'Task display entry',
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
taskId: task.id,
|
||
});
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Task display entry' }).first();
|
||
await expect(event).toBeVisible();
|
||
await expect(event).toContainText(taskName);
|
||
await expect(event).toContainText(clientName);
|
||
});
|
||
|
||
test('event color uses project color blended with background', async ({ page, ctx }) => {
|
||
const project = await createProjectViaApi(ctx, {
|
||
name: 'Color Project ' + Math.floor(Math.random() * 10000),
|
||
color: '#ef5350',
|
||
});
|
||
await createTimeEntryViaApi(ctx, {
|
||
description: 'Color test',
|
||
duration: '1h',
|
||
projectId: project.id,
|
||
});
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Color test' }).first();
|
||
await expect(event).toBeVisible();
|
||
// The event should have a background color that is NOT the raw project color
|
||
// but a blended version. Just verify it has a background-color style set.
|
||
const bgColor = await event.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||
expect(bgColor).not.toBe('');
|
||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||
// The raw #ef5350 = rgb(239, 83, 80). The blended color should differ.
|
||
expect(bgColor).not.toBe('rgb(239, 83, 80)');
|
||
});
|
||
|
||
test('event without project uses default gray color', async ({ page, ctx }) => {
|
||
await createBareTimeEntryViaApi(ctx, 'No project entry', '1h');
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'No project entry' }).first();
|
||
await expect(event).toBeVisible();
|
||
const bgColor = await event.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||
});
|
||
|
||
test('overlapping events render side by side', async ({ page, ctx }) => {
|
||
// Create 2 overlapping entries using explicit timestamps
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Overlap A', start, end });
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Overlap B', start, end });
|
||
await goToCalendar(page);
|
||
const eventA = page.locator('.fc-event').filter({ hasText: 'Overlap A' }).first();
|
||
const eventB = page.locator('.fc-event').filter({ hasText: 'Overlap B' }).first();
|
||
await expect(eventA).toBeVisible();
|
||
await expect(eventB).toBeVisible();
|
||
// They should not fully overlap — check they have different x positions or widths
|
||
const boxA = await eventA.boundingBox();
|
||
const boxB = await eventB.boundingBox();
|
||
expect(boxA).not.toBeNull();
|
||
expect(boxB).not.toBeNull();
|
||
// FullCalendar places overlapping events side by side, so widths should be less than full column
|
||
// or x positions should differ
|
||
const xDiff = Math.abs(boxA!.x - boxB!.x);
|
||
const combinedWidth = boxA!.width + boxB!.width;
|
||
// Either they're at different positions or they're both narrower
|
||
expect(xDiff > 5 || combinedWidth < boxA!.width * 3).toBeTruthy();
|
||
});
|
||
|
||
test('very short event still renders visibly', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(10, 5); // 5 minutes
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Short event', start, end });
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Short event' }).first();
|
||
await expect(event).toBeVisible();
|
||
const box = await event.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
expect(box!.height).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('running entry has distinct visual style', async ({ page, ctx }) => {
|
||
await createRunningTimeEntryViaApi(ctx, 'Running style test');
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Running style test' }).first();
|
||
await expect(event).toBeVisible();
|
||
// Running entries should have the running-entry class
|
||
await expect(event).toHaveClass(/running-entry/);
|
||
});
|
||
|
||
test('entry with no description shows fallback text', async ({ page, ctx }) => {
|
||
await createTimeEntryViaApi(ctx, { description: '', duration: '1h' });
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'No description' }).first();
|
||
await expect(event).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 2: Drag-to-Move Events
|
||
// =============================================
|
||
|
||
test.describe('Drag-to-Move Events', () => {
|
||
test('drag event to different time slot on same day', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Drag time test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Drag time test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
|
||
// Drag the event down by 2 hours (8 x 15-min slots)
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
await page.mouse.move(
|
||
eventBox!.x + eventBox!.width / 2,
|
||
eventBox!.y + slotHeight * 8,
|
||
{
|
||
steps: 15,
|
||
}
|
||
);
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
// Start should have changed from 10:00
|
||
expect(body.data.start).not.toContain('T10:00:00');
|
||
// Duration should be preserved (1 hour)
|
||
const startDate = new Date(body.data.start);
|
||
const endDate = new Date(body.data.end);
|
||
const durationMs = endDate.getTime() - startDate.getTime();
|
||
expect(durationMs).toBe(3600000); // 1 hour
|
||
});
|
||
|
||
test('drag event to different day', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Drag day test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Get all column headers to find a different day (headers are always visible)
|
||
const headers = page.locator('.fc-col-header-cell');
|
||
const headerCount = await headers.count();
|
||
let targetX: number | undefined;
|
||
// Find a column that's not today
|
||
for (let i = 0; i < headerCount; i++) {
|
||
const header = headers.nth(i);
|
||
const hasToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||
if (!hasToday) {
|
||
const box = await header.boundingBox();
|
||
targetX = box!.x + box!.width / 2;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Drag day test' }).first();
|
||
await expect(event).toBeVisible();
|
||
const eventBox = await event.boundingBox();
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
// The date should have changed
|
||
const originalDate = new Date(start).toISOString().split('T')[0];
|
||
const newDate = new Date(body.data.start).toISOString().split('T')[0];
|
||
expect(newDate).not.toBe(originalDate);
|
||
});
|
||
|
||
test('drag preserves original event duration', async ({ page, ctx }) => {
|
||
const start = todayAt(9);
|
||
const end = todayAt(11); // 2 hours
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Duration preserve test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '08:00:00');
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Duration preserve test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
|
||
// Drag the event down by 2 hours (8 x 15-min slots)
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
await page.mouse.move(
|
||
eventBox!.x + eventBox!.width / 2,
|
||
eventBox!.y + slotHeight * 8,
|
||
{
|
||
steps: 15,
|
||
}
|
||
);
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const startDate = new Date(body.data.start);
|
||
const endDate = new Date(body.data.end);
|
||
const durationMs = endDate.getTime() - startDate.getTime();
|
||
expect(durationMs).toBe(7200000); // 2 hours preserved
|
||
});
|
||
|
||
test('running entry cannot be dragged', async ({ page, ctx }) => {
|
||
await createRunningTimeEntryViaApi(ctx, 'No drag running');
|
||
await goToCalendar(page);
|
||
// Scroll to make the running entry visible (it started ~10min ago)
|
||
const nowHour = new Date().getHours();
|
||
const scrollTime = `${String(Math.max(0, nowHour - 1)).padStart(2, '0')}:00:00`;
|
||
await scrollCalendarToTime(page, scrollTime);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'No drag running' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const originalY = eventBox!.y;
|
||
|
||
// Try to drag
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
await page.mouse.move(eventBox!.x, eventBox!.y + 100, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Wait a bit for any potential update
|
||
await page.waitForTimeout(500);
|
||
|
||
// Event should still be at original position (approximately)
|
||
const newBox = await event.boundingBox();
|
||
expect(Math.abs(newBox!.y - originalY)).toBeLessThan(26);
|
||
});
|
||
|
||
test('cross-day drag preserves time of day and duration', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Cross day preserve test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Find a non-today column header to get the target X coordinate
|
||
const headers = page.locator('.fc-col-header-cell');
|
||
const headerCount = await headers.count();
|
||
let targetX: number | undefined;
|
||
for (let i = 0; i < headerCount; i++) {
|
||
const header = headers.nth(i);
|
||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||
if (!isToday) {
|
||
const box = await header.boundingBox();
|
||
targetX = box!.x + box!.width / 2;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Cross day preserve test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
const eventBox = await event.boundingBox();
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
// Move to different day, same Y (preserves time of day)
|
||
await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, {
|
||
steps: 15,
|
||
});
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
const origStart = new Date(start);
|
||
|
||
// Date should have changed
|
||
expect(newStart.toISOString().split('T')[0]).not.toBe(
|
||
origStart.toISOString().split('T')[0]
|
||
);
|
||
// Duration should be preserved (1 hour = 3600000ms)
|
||
expect(newEnd.getTime() - newStart.getTime()).toBe(3600000);
|
||
});
|
||
|
||
test('cross-day drag shows faded ghost in original column', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(12);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Ghost preview test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Find a non-today column header
|
||
const headers = page.locator('.fc-col-header-cell');
|
||
const headerCount = await headers.count();
|
||
let targetX: number | undefined;
|
||
for (let i = 0; i < headerCount; i++) {
|
||
const header = headers.nth(i);
|
||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||
if (!isToday) {
|
||
const box = await header.boundingBox();
|
||
targetX = box!.x + box!.width / 2;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Ghost preview test' }).first();
|
||
await expect(event).toBeVisible();
|
||
const eventBox = await event.boundingBox();
|
||
|
||
// Start dragging to another day but don't release
|
||
await event.hover();
|
||
await page.mouse.down();
|
||
await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, {
|
||
steps: 15,
|
||
});
|
||
|
||
// While dragging across days, the original event should be faded (opacity ~0.3)
|
||
const opacity = await event.evaluate((el) =>
|
||
parseFloat(window.getComputedStyle(el).opacity)
|
||
);
|
||
expect(opacity).toBeLessThanOrEqual(0.4);
|
||
|
||
// A cross-day preview should appear in the target column
|
||
const preview = page.locator('.fc-cross-day-preview');
|
||
await expect(preview).toBeVisible();
|
||
|
||
await page.mouse.up();
|
||
});
|
||
|
||
test('dragging single-day event upward past midnight spills to previous day', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
|
||
const start = toApiTimestamp(thursdayAt(0, 30));
|
||
const end = toApiTimestamp(thursdayAt(1, 30));
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Drag up past midnight test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '00:00:00');
|
||
|
||
const thursdayCol = page.locator(
|
||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||
);
|
||
const event = thursdayCol
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Drag up past midnight test' });
|
||
await expect(event).toBeVisible({ timeout: 10000 });
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const slotHeight = await getSlotHeight(page);
|
||
const startX = eventBox!.x + eventBox!.width / 2;
|
||
const startY = eventBox!.y + eventBox!.height / 2;
|
||
|
||
// Drag up by ~3 slots (45 minutes) — should push the event past midnight to the previous day
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(startX, startY);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(startX, startY - slotHeight * 3, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
const newDurationMs = newEnd.getTime() - newStart.getTime();
|
||
|
||
// Duration must be preserved (1 hour)
|
||
expect(newDurationMs).toBe(3600000);
|
||
|
||
// The event should have moved to the previous day
|
||
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 3: Resize Events
|
||
// =============================================
|
||
|
||
test.describe('Resize Events', () => {
|
||
test('resize event from bottom edge extends duration', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Resize bottom test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Resize bottom test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
// Bottom edge of the event
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
// Hover to show resize handle, then drag from bottom
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
// Drag down by ~1 hour worth of pixels (4 slots of 15 min)
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const startDate = new Date(body.data.start);
|
||
const endDate = new Date(body.data.end);
|
||
const durationMs = endDate.getTime() - startDate.getTime();
|
||
// Should be longer than 1 hour now
|
||
expect(durationMs).toBeGreaterThan(3600000);
|
||
});
|
||
|
||
test('resize event from top edge changes start time', async ({ page, ctx }) => {
|
||
const start = todayAt(12);
|
||
const end = todayAt(14);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Resize top test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '10:00:00');
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Resize top test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const topY = eventBox!.y;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, topY + 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
const oneHourPx = slotHeight * 4;
|
||
await page.mouse.move(centerX, topY - oneHourPx, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
// End should be preserved
|
||
const endDate = new Date(body.data.end);
|
||
expect(endDate.getUTCHours()).toBe(new Date(end).getUTCHours());
|
||
// Start should have moved earlier
|
||
const startDate = new Date(body.data.start);
|
||
expect(startDate.getTime()).toBeLessThan(new Date(start).getTime());
|
||
});
|
||
|
||
test('resize preserves the non-resized edge', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(12);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Preserve edge test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Preserve edge test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
// Start should be unchanged
|
||
const startDate = new Date(body.data.start);
|
||
const origStart = new Date(start);
|
||
expect(Math.abs(startDate.getTime() - origStart.getTime())).toBeLessThan(60000);
|
||
});
|
||
|
||
test('running entry cannot be resized from bottom', async ({ page, ctx }) => {
|
||
await createRunningTimeEntryViaApi(ctx, 'No bottom resize');
|
||
await goToCalendar(page);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'No bottom resize' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// The bottom resize handle should be hidden via CSS
|
||
const endResizer = event.locator('.fc-event-resizer-end');
|
||
// It might exist in DOM but be display:none
|
||
if ((await endResizer.count()) > 0) {
|
||
await expect(endResizer).toBeHidden();
|
||
}
|
||
});
|
||
|
||
test('running entry start can be changed via top-edge resize', async ({ page, ctx }) => {
|
||
const now = new Date();
|
||
const startTime = new Date(now.getTime() - 2 * 60 * 60 * 1000);
|
||
// Skip if start would be on a different day (near midnight UTC)
|
||
test.skip(
|
||
startTime.getUTCDate() !== now.getUTCDate(),
|
||
'Skipping near midnight UTC to avoid cross-day issues'
|
||
);
|
||
const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
await createRunningTimeEntryWithStartViaApi(ctx, 'Resize running start', startStr);
|
||
await goToCalendar(page);
|
||
// Scroll to make the running entry's top edge visible
|
||
const scrollHour = Math.max(0, startTime.getHours() - 1);
|
||
await scrollCalendarToTime(page, `${String(scrollHour).padStart(2, '0')}:00:00`);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Resize running start' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// Use the start resizer element directly for more reliable interaction
|
||
const startResizer = event.locator('.fc-event-resizer-start');
|
||
const resizerBox = await startResizer.boundingBox();
|
||
const centerX = resizerBox!.x + resizerBox!.width / 2;
|
||
const resizerY = resizerBox!.y + resizerBox!.height / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) => r.url().includes('/time-entries/') && r.request().method() === 'PUT'
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, resizerY);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
const slotLane = page.locator('.fc-timegrid-slot-lane').first();
|
||
const slotHeight = (await slotLane.boundingBox())!.height;
|
||
// Move down (make it start later)
|
||
await page.mouse.move(centerX, resizerY + slotHeight * 4, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
expect(putResponse.status()).toBe(200);
|
||
const body = await putResponse.json();
|
||
// End should remain null for running entries
|
||
expect(body.data.end).toBeNull();
|
||
// Start should have moved later
|
||
expect(new Date(body.data.start).getTime()).toBeGreaterThan(startTime.getTime());
|
||
});
|
||
|
||
test('running entry resize preserves end:null in API response', async ({ page, ctx }) => {
|
||
const now = new Date();
|
||
const startTime = new Date(now.getTime() - 1 * 60 * 60 * 1000);
|
||
// Skip if start would be on a different day (near midnight UTC)
|
||
test.skip(
|
||
startTime.getUTCDate() !== now.getUTCDate(),
|
||
'Skipping near midnight UTC to avoid cross-day issues'
|
||
);
|
||
const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
await createRunningTimeEntryWithStartViaApi(ctx, 'End null preserve', startStr);
|
||
await goToCalendar(page);
|
||
// Scroll to make the running entry's top edge visible
|
||
const scrollHour = Math.max(0, startTime.getHours() - 1);
|
||
await scrollCalendarToTime(page, `${String(scrollHour).padStart(2, '0')}:00:00`);
|
||
const event = page.locator('.fc-event').filter({ hasText: 'End null preserve' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const topY = eventBox!.y;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Intercept the PUT request to check what's sent
|
||
const [putRequest] = await Promise.all([
|
||
page.waitForRequest((r) => r.url().includes('/time-entries/') && r.method() === 'PUT'),
|
||
(async () => {
|
||
await page.mouse.move(centerX, topY + 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
const slotLane = page.locator('.fc-timegrid-slot-lane').first();
|
||
const slotHeight = (await slotLane.boundingBox())!.height;
|
||
await page.mouse.move(centerX, topY + slotHeight * 2, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const requestBody = putRequest.postDataJSON();
|
||
expect(requestBody.end).toBeNull();
|
||
});
|
||
|
||
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
||
const start = toApiTimestamp(wednesdayAt(10));
|
||
const end = toApiTimestamp(wednesdayAt(11));
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Cross resize end test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Resize into Thursday's column (for end resize, end must be > start)
|
||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Cross resize end test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
// Same approach as working test 3.1: hover bottom, drag down
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
// First drag down vertically to engage resize (like test 3.1)
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||
// Then move horizontally to a later day column
|
||
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const origStart = new Date(start);
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
|
||
// Start should be preserved
|
||
expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000);
|
||
// End date should have changed to a different day
|
||
expect(newEnd.toISOString().split('T')[0]).not.toBe(
|
||
new Date(end).toISOString().split('T')[0]
|
||
);
|
||
});
|
||
|
||
test('resize bottom edge across day boundary changes end date backward', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
const start = toApiTimestamp(wednesdayAt(10));
|
||
const end = toApiTimestamp(wednesdayAt(14));
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Cross resize end test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Resize into Thursday's column (later day needed for end-edge resize)
|
||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Cross resize end test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
// Hover bottom edge to show resize handle
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
// Move to a different day column at same Y position
|
||
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const origStart = new Date(start);
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
|
||
// Start should be preserved
|
||
expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000);
|
||
// End date should have changed to a different day
|
||
expect(newEnd.toISOString().split('T')[0]).not.toBe(
|
||
new Date(end).toISOString().split('T')[0]
|
||
);
|
||
});
|
||
|
||
test('cross-day resize shows preview in target column', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(14);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Resize preview test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Find any non-today column header (preview only, direction doesn't matter)
|
||
const headers = page.locator('.fc-col-header-cell');
|
||
const headerCount = await headers.count();
|
||
let targetX: number | undefined;
|
||
for (let i = 0; i < headerCount; i++) {
|
||
const header = headers.nth(i);
|
||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||
if (!isToday) {
|
||
const box = await header.boundingBox();
|
||
targetX = box!.x + box!.width / 2;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Resize preview test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Start resize from bottom edge (drag down first, then sideways)
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 5 });
|
||
await page.mouse.move(targetX!, bottomY + slotHeight * 2, { steps: 15 });
|
||
|
||
// Cross-day preview should be visible (may appear in multiple columns)
|
||
const preview = page.locator('.fc-cross-day-preview').first();
|
||
await expect(preview).toBeVisible();
|
||
|
||
await page.mouse.up();
|
||
});
|
||
|
||
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
||
// Create entry spanning Wednesday evening → Thursday morning
|
||
const start = toApiTimestamp(wednesdayAt(20));
|
||
const end = toApiTimestamp(thursdayAt(10));
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi-day end resize',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
const thursdayCol = page.locator(
|
||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||
);
|
||
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Find the event segment on Thursday's column
|
||
const event = thursdayCol
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Multi-day end resize' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Resize bottom edge down by 2 slots
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
const origStart = new Date(start);
|
||
const origEnd = new Date(end);
|
||
|
||
// Start must be preserved
|
||
expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000);
|
||
// End should be later than original
|
||
expect(newEnd.getTime()).toBeGreaterThan(origEnd.getTime());
|
||
// End must be after start
|
||
expect(newEnd.getTime()).toBeGreaterThan(newStart.getTime());
|
||
});
|
||
|
||
test('multi-day event end resize backward to start day produces valid entry', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Create entry spanning Wednesday → Thursday
|
||
const start = toApiTimestamp(wednesdayAt(10));
|
||
const end = toApiTimestamp(thursdayAt(14));
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Backward end resize multi',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
const thursdayCol = page.locator(
|
||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||
);
|
||
|
||
await scrollCalendarToTime(page, '12:00:00');
|
||
|
||
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||
|
||
// Find event segment on Thursday's column and resize end backward to Wednesday
|
||
const event = thursdayCol
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Backward end resize multi' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
// Drag down a bit first, then move to Wednesday's column at a Y after the start
|
||
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
||
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
|
||
// End must be after start (the core invariant)
|
||
expect(newEnd.getTime()).toBeGreaterThan(newStart.getTime());
|
||
// Start should be preserved
|
||
expect(Math.abs(newStart.getTime() - new Date(start).getTime())).toBeLessThan(60000);
|
||
});
|
||
|
||
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
||
const start = toApiTimestamp(thursdayAt(10));
|
||
const end = toApiTimestamp(thursdayAt(14));
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'End before start test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Target Wednesday's column, the day before the entry
|
||
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'End before start test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Collect PUT request bodies to verify none has end < start
|
||
const putBodies: any[] = [];
|
||
page.on('request', (req) => {
|
||
if (req.url().includes('/time-entries/') && req.method() === 'PUT') {
|
||
try {
|
||
putBodies.push(req.postDataJSON());
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
// Resize end toward earlier column at a Y that would place end before start
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
// Move to earlier column at a Y position near the top of the grid (before start time)
|
||
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||
await page.mouse.up();
|
||
|
||
// Wait for any potential API call
|
||
await page.waitForTimeout(1000);
|
||
|
||
// If any PUT was made, end must be after start
|
||
for (const body of putBodies) {
|
||
if (body.end !== null) {
|
||
const putStart = new Date(body.start);
|
||
const putEnd = new Date(body.end);
|
||
expect(putEnd.getTime()).toBeGreaterThan(putStart.getTime());
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 4: Click-Drag Selection to Create
|
||
// =============================================
|
||
|
||
test.describe('Click-Drag Selection to Create', () => {
|
||
test('completing selection opens create modal with correct times', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Find the 10:00 slot
|
||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
||
await expect(startSlot).toBeVisible();
|
||
const startBox = await startSlot.boundingBox();
|
||
|
||
// Find the 11:00 slot (to select 1 hour)
|
||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||
const endBox = await endSlot.boundingBox();
|
||
|
||
// Click-drag from 10:00 to 11:00
|
||
await page.mouse.move(startBox!.x + startBox!.width / 2, startBox!.y + 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(endBox!.x + endBox!.width / 2, endBox!.y + 2, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Create modal should appear
|
||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
||
page,
|
||
}) => {
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
// Use mid-day times so both start and end slots are visible in the viewport
|
||
await scrollCalendarToTime(page, '10:00:00');
|
||
|
||
// Find Wednesday's and Thursday's columns
|
||
const wednesdayStr = toDateStr(wednesdayAt(0));
|
||
const thursdayStr = toDateStr(thursdayAt(0));
|
||
|
||
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
|
||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
|
||
await expect(wednesdayCol).toBeVisible();
|
||
await expect(thursdayCol).toBeVisible();
|
||
|
||
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
|
||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
||
await expect(startSlot).toBeVisible();
|
||
await expect(endSlot).toBeVisible();
|
||
|
||
const startSlotBox = await startSlot.boundingBox();
|
||
const endSlotBox = await endSlot.boundingBox();
|
||
const wednesdayColBox = await wednesdayCol.boundingBox();
|
||
const thursdayColBox = await thursdayCol.boundingBox();
|
||
|
||
// Start drag at 11:00 on Wednesday's column
|
||
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
|
||
const startY = startSlotBox!.y + 2;
|
||
|
||
// End drag at 13:00 on Thursday's column
|
||
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
|
||
const endY = endSlotBox!.y + 2;
|
||
|
||
// Drag from Wednesday to Thursday, moving down first, then across
|
||
const slotHeight = await getSlotHeight(page);
|
||
await page.mouse.move(startX, startY);
|
||
await page.mouse.down();
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.move(startX, startY + slotHeight * 2, { steps: 5 });
|
||
await page.mouse.move(endX, startY + slotHeight * 2, { steps: 10 });
|
||
await page.mouse.move(endX, endY, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Create modal should appear
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||
|
||
// Verify the dialog spans across two days
|
||
await expect(dialog.getByText('Start')).toBeVisible();
|
||
await expect(dialog.getByText('End')).toBeVisible();
|
||
|
||
// Start date should be Wednesday, end date should be Thursday
|
||
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
|
||
await expect(dialog.getByText(thursdayStr)).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 6: Timezone & Localization
|
||
// =============================================
|
||
|
||
test.describe('Timezone & Localization', () => {
|
||
test('week start day: monday shows Mon as first column', async ({ page, ctx }) => {
|
||
await updateUserProfileViaApi(ctx, { week_start: 'monday' });
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
const firstHeader = page.locator('.fc-col-header-cell').first();
|
||
await expect(firstHeader).toContainText('Mon');
|
||
});
|
||
|
||
test('week start day: sunday shows Sun as first column', async ({ page, ctx }) => {
|
||
await updateUserProfileViaApi(ctx, { week_start: 'sunday' });
|
||
await goToCalendar(page);
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
|
||
const firstHeader = page.locator('.fc-col-header-cell').first();
|
||
await expect(firstHeader).toContainText('Sun');
|
||
});
|
||
|
||
test('12-hour time format shows AM/PM on slot labels', async ({ page, ctx }) => {
|
||
await updateOrganizationSettingViaApi(ctx, { time_format: '12-hours' });
|
||
await page.reload();
|
||
await goToCalendar(page);
|
||
|
||
// Look for AM/PM in slot labels
|
||
const slotLabels = page.locator('.fc-timegrid-slot-label-cushion');
|
||
const allText = await slotLabels.allTextContents();
|
||
const hasAmPm = allText.some((t) => t.includes('AM') || t.includes('PM'));
|
||
expect(hasAmPm).toBeTruthy();
|
||
|
||
// Reset
|
||
await updateOrganizationSettingViaApi(ctx, { time_format: '24-hours' });
|
||
});
|
||
|
||
test('24-hour time format does not show AM/PM on slot labels', async ({ page, ctx }) => {
|
||
await updateOrganizationSettingViaApi(ctx, { time_format: '24-hours' });
|
||
await page.reload();
|
||
await goToCalendar(page);
|
||
|
||
const slotLabels = page.locator('.fc-timegrid-slot-label-cushion');
|
||
const allText = await slotLabels.allTextContents();
|
||
// Should NOT contain AM/PM
|
||
const hasAmPm = allText.some((t) => t.includes('AM') || t.includes('PM'));
|
||
expect(hasAmPm).toBeFalsy();
|
||
// Should contain 24h format like "08:00" or "14:00"
|
||
const has24h = allText.some((t) => /^\d{2}:\d{2}$/.test(t.trim()));
|
||
expect(has24h).toBeTruthy();
|
||
});
|
||
|
||
test('interval format reflected in event duration display', async ({ page, ctx }) => {
|
||
await updateOrganizationSettingViaApi(ctx, {
|
||
interval_format: 'hours-minutes-colon-separated',
|
||
});
|
||
await createBareTimeEntryViaApi(ctx, 'Duration format test', '1h 30min');
|
||
await page.reload();
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Duration format test' }).first();
|
||
await expect(event).toBeVisible();
|
||
// Should show "1:30" instead of "1h 30min"
|
||
const durationEl = event.locator('[data-duration]');
|
||
await expect(durationEl).toContainText('1:30');
|
||
|
||
// Reset
|
||
await updateOrganizationSettingViaApi(ctx, { interval_format: 'hours-minutes' });
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 7: Multi-Day Events
|
||
// =============================================
|
||
|
||
test.describe('Multi-Day Events', () => {
|
||
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
||
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
|
||
const start = toApiTimestamp(wednesdayAt(22));
|
||
const end = toApiTimestamp(thursdayAt(2));
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi day entry',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
// Wait for the event to appear with retrying
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Multi day entry' }).first();
|
||
await expect(event).toBeVisible({ timeout: 10000 });
|
||
});
|
||
|
||
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
||
const start = toApiTimestamp(wednesdayAt(22));
|
||
const end = toApiTimestamp(thursdayAt(2));
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi day edit test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Multi day edit test' }).first();
|
||
await expect(event).toBeVisible();
|
||
await event.click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
await expect(
|
||
page.getByRole('dialog').getByPlaceholder('What did you work on?')
|
||
).toHaveValue('Multi day edit test');
|
||
});
|
||
|
||
test('multi-day event context menu works', async ({ page, ctx }) => {
|
||
const start = toApiTimestamp(wednesdayAt(22));
|
||
const end = toApiTimestamp(thursdayAt(2));
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi day ctx test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
await openContextMenu(page, 'Multi day ctx test');
|
||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Split' })).toBeVisible();
|
||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||
});
|
||
|
||
test('dragging clipped segment of multi-day event preserves cross-day span', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||
const startDate = wednesdayAt(22);
|
||
const endDate = thursdayAt(2);
|
||
const start = toApiTimestamp(startDate);
|
||
const end = toApiTimestamp(endDate);
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi day drag test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '00:00:00');
|
||
|
||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||
await expect(event).toBeVisible({ timeout: 10000 });
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const slotHeight = await getSlotHeight(page);
|
||
|
||
// Drag the clipped segment down by ~1 slot (15 minutes)
|
||
const startX = eventBox!.x + eventBox!.width / 2;
|
||
const startY = eventBox!.y + eventBox!.height / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(startX, startY);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(startX, startY + slotHeight, { steps: 10 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
const newDurationMs = newEnd.getTime() - newStart.getTime();
|
||
const origDurationMs = endDate.getTime() - startDate.getTime();
|
||
|
||
// Duration must be preserved (4 hours)
|
||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||
|
||
// The start should still be on Wednesday (not jumped to Thursday)
|
||
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
|
||
});
|
||
|
||
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||
const startDate = wednesdayAt(22);
|
||
const endDate = thursdayAt(2);
|
||
const start = toApiTimestamp(startDate);
|
||
const end = toApiTimestamp(endDate);
|
||
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Multi day drag up test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '00:00:00');
|
||
|
||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||
const event = thursdayCol
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Multi day drag up test' });
|
||
await expect(event).toBeVisible({ timeout: 10000 });
|
||
|
||
const eventBox = await event.boundingBox();
|
||
const slotHeight = await getSlotHeight(page);
|
||
|
||
// Drag the clipped segment UP by ~1 slot (15 minutes)
|
||
const startX = eventBox!.x + eventBox!.width / 2;
|
||
const startY = eventBox!.y + eventBox!.height / 2;
|
||
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(startX, startY);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(startX, startY - slotHeight, { steps: 10 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const newStart = new Date(body.data.start);
|
||
const newEnd = new Date(body.data.end);
|
||
const newDurationMs = newEnd.getTime() - newStart.getTime();
|
||
const origDurationMs = endDate.getTime() - startDate.getTime();
|
||
|
||
// Duration must be preserved (4 hours)
|
||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||
|
||
// The event should have shifted earlier by ~15 minutes
|
||
// Original start was 22:00, new start should be ~21:45
|
||
const shiftMs = startDate.getTime() - newStart.getTime();
|
||
expect(shiftMs).toBeGreaterThan(10 * 60 * 1000); // shifted at least 10 minutes earlier
|
||
expect(shiftMs).toBeLessThan(20 * 60 * 1000); // but not more than 20 minutes
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 9: Now Indicator
|
||
// =============================================
|
||
|
||
test.describe('Now Indicator', () => {
|
||
test('now indicator is visible on current day', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
// FullCalendar v6 uses fc-timegrid-now-indicator-line for the now indicator
|
||
await expect(async () => {
|
||
const count = await page.locator('.fc-timegrid-now-indicator-line').count();
|
||
expect(count).toBeGreaterThan(0);
|
||
}).toPass({ timeout: 10000 });
|
||
});
|
||
|
||
test('now indicator is not visible on past weeks', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
// Navigate to two weeks ago
|
||
await page.getByRole('button', { name: 'Previous' }).click();
|
||
await page.getByRole('button', { name: 'Previous' }).click();
|
||
await expect(page.locator('.fc')).toBeVisible();
|
||
// Now indicator line should not be present in past weeks
|
||
await expect(page.locator('.fc-timegrid-now-indicator-line')).toHaveCount(0);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 10: Day Header & Totals
|
||
// =============================================
|
||
|
||
test.describe('Day Header & Totals', () => {
|
||
test('day header shows correct day name for today', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const todayName = days[new Date().getDay()];
|
||
// The today column should contain the day name
|
||
const todayHeader = page.locator('.fc-day-today.fc-col-header-cell');
|
||
await expect(todayHeader).toContainText(todayName);
|
||
});
|
||
|
||
test('day header shows daily total duration', async ({ page, ctx }) => {
|
||
// Create 2 entries: 1h + 30min = 1h 30min total
|
||
const start1 = todayAt(9);
|
||
const end1 = todayAt(10);
|
||
const start2 = todayAt(11);
|
||
const end2 = todayAt(11, 30);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Total A',
|
||
start: start1,
|
||
end: end1,
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Total B',
|
||
start: start2,
|
||
end: end2,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
// Wait for entries to appear
|
||
await expect(
|
||
page.locator('.fc-event').filter({ hasText: 'Total A' }).first()
|
||
).toBeVisible();
|
||
await expect(
|
||
page.locator('.fc-event').filter({ hasText: 'Total B' }).first()
|
||
).toBeVisible();
|
||
|
||
// The today header should show the total (default format: "1h 30min")
|
||
const todayHeader = page.locator('.fc-day-today.fc-col-header-cell');
|
||
await expect(todayHeader).toContainText('1h 30min');
|
||
});
|
||
|
||
test('daily total updates after entry deletion', async ({ page, ctx }) => {
|
||
const start = todayAt(9);
|
||
const end = todayAt(10);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Delete total test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
|
||
const todayHeader = page.locator('.fc-day-today.fc-col-header-cell');
|
||
// Should show 1h initially
|
||
await expect(todayHeader).toContainText('1h 00min');
|
||
|
||
// Delete the entry via context menu
|
||
await openContextMenu(page, 'Delete total test');
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'DELETE' &&
|
||
r.status() === 204
|
||
),
|
||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||
]);
|
||
|
||
// Total should update to 0
|
||
await expect(todayHeader).toContainText('0h 00min');
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 11: Activity Plugin Overlays
|
||
// =============================================
|
||
|
||
test.describe('Activity Plugin Overlays', () => {
|
||
test('activity periods render as colored bars on calendar', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
// Wait for FullCalendar to fully render its time grid
|
||
await page.waitForTimeout(1000);
|
||
|
||
const now = new Date();
|
||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||
|
||
// Inject activity data via the exposed setter function
|
||
await page.evaluate((dateStr: string) => {
|
||
const setter = (window as any).__TEST_SET_ACTIVITY_PERIODS__;
|
||
if (setter) {
|
||
setter([
|
||
{
|
||
start: `${dateStr}T09:00:00Z`,
|
||
end: `${dateStr}T09:30:00Z`,
|
||
isIdle: false,
|
||
windowActivities: [{ appName: 'VSCode', url: null, count: 20 }],
|
||
},
|
||
{
|
||
start: `${dateStr}T09:30:00Z`,
|
||
end: `${dateStr}T10:00:00Z`,
|
||
isIdle: true,
|
||
},
|
||
]);
|
||
}
|
||
}, todayStr);
|
||
|
||
// Activity boxes should appear
|
||
const activityBoxes = page.locator('.activity-status-box');
|
||
await expect(activityBoxes.first()).toBeVisible({ timeout: 10000 });
|
||
const count = await activityBoxes.count();
|
||
expect(count).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
test('idle and active periods have different styles', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await page.waitForTimeout(1000);
|
||
|
||
const now = new Date();
|
||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||
|
||
await page.evaluate((dateStr: string) => {
|
||
const setter = (window as any).__TEST_SET_ACTIVITY_PERIODS__;
|
||
if (setter) {
|
||
setter([
|
||
{
|
||
start: `${dateStr}T09:00:00Z`,
|
||
end: `${dateStr}T09:30:00Z`,
|
||
isIdle: false,
|
||
},
|
||
{
|
||
start: `${dateStr}T09:30:00Z`,
|
||
end: `${dateStr}T10:00:00Z`,
|
||
isIdle: true,
|
||
},
|
||
]);
|
||
}
|
||
}, todayStr);
|
||
|
||
const activeBox = page.locator('.activity-status-box.active');
|
||
const idleBox = page.locator('.activity-status-box.idle');
|
||
await expect(activeBox.first()).toBeVisible({ timeout: 10000 });
|
||
await expect(idleBox.first()).toBeVisible({ timeout: 10000 });
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 12: Running Entry Behavior
|
||
// =============================================
|
||
|
||
test.describe('Running Entry Behavior', () => {
|
||
test('running entry extends to approximately current time', async ({ page, ctx }) => {
|
||
const now = new Date();
|
||
const startTime = new Date(now.getTime() - 1 * 60 * 60 * 1000);
|
||
// Skip if start would be on a different day (near midnight UTC)
|
||
test.skip(
|
||
startTime.getUTCDate() !== now.getUTCDate(),
|
||
'Skipping near midnight UTC to avoid cross-day issues'
|
||
);
|
||
const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
await createRunningTimeEntryWithStartViaApi(ctx, 'Running extends test', startStr);
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Running extends test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// Event should have significant height (extends to now, ~1 hour)
|
||
const box = await event.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
// A 1-hour event with default slot settings should be at least 30px tall
|
||
expect(box!.height).toBeGreaterThan(30);
|
||
});
|
||
|
||
test('running entry has distinct running-entry class', async ({ page, ctx }) => {
|
||
await createRunningTimeEntryViaApi(ctx, 'Single running test');
|
||
await goToCalendar(page);
|
||
|
||
// Should have exactly one running-entry element
|
||
const runningEvents = page.locator('.fc-event.running-entry');
|
||
await expect(runningEvents).toHaveCount(1);
|
||
});
|
||
|
||
test('stopping running entry removes running-entry styling', async ({ page, ctx }) => {
|
||
await createRunningTimeEntryViaApi(ctx, 'Stop style test');
|
||
await goToCalendar(page);
|
||
|
||
// Verify it has running-entry class
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Stop style test' }).first();
|
||
await expect(event).toHaveClass(/running-entry/);
|
||
|
||
// Stop it via context menu
|
||
await openContextMenu(page, 'Stop style test');
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
page.getByRole('menuitem', { name: 'Stop' }).click(),
|
||
]);
|
||
|
||
// After stopping, event should no longer have running-entry class
|
||
// Wait for the calendar to re-render after the mutation
|
||
await page.waitForTimeout(1000);
|
||
const stoppedEvent = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Stop style test' })
|
||
.first();
|
||
await expect(stoppedEvent).toBeVisible();
|
||
await expect(stoppedEvent).not.toHaveClass(/running-entry/);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 13: Data Loading & Navigation
|
||
// =============================================
|
||
|
||
test.describe('Data Loading & Navigation', () => {
|
||
test('calendar scroller renders with correct grid height', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await page.waitForSelector('.fc-timegrid-slot-lane', { timeout: 5000 });
|
||
|
||
const info = await page.evaluate(() => {
|
||
const scroller = document.querySelector('.fc-scroller');
|
||
if (!scroller) return { exists: false, scrollHeight: 0, slotCount: 0 };
|
||
const slots = scroller.querySelectorAll('.fc-timegrid-slot-lane');
|
||
return {
|
||
exists: true,
|
||
scrollHeight: scroller.scrollHeight,
|
||
slotCount: slots.length,
|
||
};
|
||
});
|
||
|
||
expect(info.exists).toBe(true);
|
||
// Default settings: 24h with 15-min slots = 96 slots
|
||
expect(info.slotCount).toBe(96);
|
||
// Grid height should be 96 * 25px = 2400px
|
||
expect(info.scrollHeight).toBe(2400);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 14: Keyboard & Accessibility
|
||
// =============================================
|
||
|
||
test.describe('Keyboard & Accessibility', () => {
|
||
test('edit modal can be closed with Escape', async ({ page, ctx }) => {
|
||
await createBareTimeEntryViaApi(ctx, 'Escape close test', '1h');
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Escape close test' }).first();
|
||
await event.click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||
});
|
||
|
||
test('pressing Enter on a focused event opens the edit modal', async ({ page, ctx }) => {
|
||
const description = 'Enter key test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: description }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// Focus the event and press Enter
|
||
await event.focus();
|
||
await page.keyboard.press('Enter');
|
||
|
||
// Edit modal should open
|
||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||
await expect(
|
||
page.getByRole('dialog').getByPlaceholder('What did you work on?')
|
||
).toHaveValue(description);
|
||
});
|
||
|
||
test('events have role="button" and are focusable', async ({ page, ctx }) => {
|
||
await createBareTimeEntryViaApi(ctx, 'Focusable test', '1h');
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Focusable test' }).first();
|
||
await expect(event).toBeVisible();
|
||
await expect(event).toHaveAttribute('role', 'button');
|
||
await expect(event).toHaveAttribute('tabindex', '0');
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 15: Click-to-Edit (Drag Threshold)
|
||
// =============================================
|
||
|
||
test.describe('Click-to-Edit (Drag Threshold)', () => {
|
||
test('clicking an event without dragging opens the edit modal', async ({ page, ctx }) => {
|
||
const description = 'Click edit test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: description }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// Simple click (no drag movement)
|
||
await event.click();
|
||
|
||
// Edit modal should open with the correct description
|
||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||
await expect(
|
||
page.getByRole('dialog').getByPlaceholder('What did you work on?')
|
||
).toHaveValue(description);
|
||
});
|
||
|
||
test('clicking a running entry does NOT open edit modal', async ({ page, ctx }) => {
|
||
const description = 'Running no edit test ' + Math.floor(1 + Math.random() * 10000);
|
||
await createRunningTimeEntryViaApi(ctx, description);
|
||
await goToCalendar(page);
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: description }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
// Click on the running entry
|
||
await event.click();
|
||
|
||
// Wait briefly to ensure no dialog appears
|
||
await page.waitForTimeout(500);
|
||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 16: Selection & Drag-to-Create Details
|
||
// =============================================
|
||
|
||
test.describe('Selection & Drag-to-Create Details', () => {
|
||
test('single click on empty slot opens create modal', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Click on an empty time slot (not on an event)
|
||
const slot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
||
await expect(slot).toBeVisible();
|
||
const slotBox = await slot.boundingBox();
|
||
|
||
// Click in the middle of the slot
|
||
await page.mouse.click(slotBox!.x + slotBox!.width / 2, slotBox!.y + 2);
|
||
|
||
// Create modal should appear
|
||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('drag-to-create shows selection highlight during drag', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
||
await expect(startSlot).toBeVisible();
|
||
const startBox = await startSlot.boundingBox();
|
||
|
||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||
const endBox = await endSlot.boundingBox();
|
||
|
||
// Start dragging without releasing
|
||
await page.mouse.move(startBox!.x + startBox!.width / 2, startBox!.y + 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(endBox!.x + endBox!.width / 2, endBox!.y + 2, { steps: 10 });
|
||
|
||
// Selection highlight should be visible (bg-accent border-primary class)
|
||
const selectionMirror = page.locator('.bg-accent.border-primary');
|
||
await expect(selectionMirror.first()).toBeVisible();
|
||
|
||
// Release mouse
|
||
await page.mouse.up();
|
||
});
|
||
|
||
test('snap interval affects drag-to-create times', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
|
||
// Set snap interval to 30 min
|
||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||
await page.getByLabel('Snap Interval').click();
|
||
await page.getByRole('option', { name: '30 min' }).click();
|
||
await page.keyboard.press('Escape');
|
||
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
// Drag from ~10:10 (slightly offset) to ~11:20
|
||
const slot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
|
||
const slot11 = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||
const slotHeight = await getSlotHeight(page);
|
||
const box10 = await slot10.boundingBox();
|
||
const box11 = await slot11.boundingBox();
|
||
|
||
// Start at 10:10 (slightly into the slot)
|
||
const startX = box10!.x + box10!.width / 2;
|
||
const startY = box10!.y + slotHeight * 0.7; // ~10 minutes into the slot
|
||
// End at 11:20 (past the 11:00 slot)
|
||
const endY = box11!.y + slotHeight * 1.3;
|
||
|
||
await page.mouse.move(startX, startY);
|
||
await page.mouse.down();
|
||
await page.mouse.move(startX, endY, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Create modal should appear
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||
|
||
// Submit the form and check the API request for 30-min-aligned times
|
||
const [createResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries') &&
|
||
response.request().method() === 'POST' &&
|
||
response.status() === 201
|
||
),
|
||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||
]);
|
||
|
||
const body = await createResponse.json();
|
||
const startDate = new Date(body.data.start);
|
||
const endDate = new Date(body.data.end);
|
||
|
||
// Both start and end minutes should be divisible by 30
|
||
expect(startDate.getMinutes() % 30).toBe(0);
|
||
expect(endDate.getMinutes() % 30).toBe(0);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 17: Resize Snap Interval
|
||
// =============================================
|
||
|
||
test.describe('Resize Snap Interval', () => {
|
||
test('resize bottom edge respects snap interval', async ({ page, ctx }) => {
|
||
// Set snap interval to 30 min
|
||
await goToCalendar(page);
|
||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||
await page.getByLabel('Snap Interval').click();
|
||
await page.getByRole('option', { name: '30 min' }).click();
|
||
await page.keyboard.press('Escape');
|
||
|
||
// Create entry at 10:00–11:00
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Resize snap test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Resize snap test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Resize by dragging bottom edge down ~3 slots (45 min at 15-min grid)
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
(async () => {
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 3, { steps: 15 });
|
||
await page.mouse.up();
|
||
})(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const endDate = new Date(body.data.end);
|
||
// With 30-min snap, end minutes should be divisible by 30
|
||
expect(endDate.getMinutes() % 30).toBe(0);
|
||
});
|
||
|
||
test('resize preview of off-grid event snaps to absolute grid positions', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Set snap interval to 15 min (default grid scale is also 15 min)
|
||
await goToCalendar(page);
|
||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||
await page.getByLabel('Snap Interval').click();
|
||
await page.getByRole('option', { name: '15 min' }).click();
|
||
await page.keyboard.press('Escape');
|
||
|
||
// Create entry at 10:07–10:37 — deliberately off the 15-min grid
|
||
const start = todayAt(10, 7);
|
||
const end = todayAt(10, 37);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Off-grid snap test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
const event = page.locator('.fc-event').filter({ hasText: 'Off-grid snap test' }).first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const bottomY = eventBox!.y + eventBox!.height;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Resize bottom edge down by ~2 slots (30 min at 15-min grid)
|
||
await page.mouse.move(centerX, bottomY - 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 15 });
|
||
|
||
// While still dragging, capture the preview's inline style (top + height).
|
||
// The resizing event is positioned via inline styles by the composable.
|
||
const previewStyle = await page.evaluate(() => {
|
||
// Find all fc-event elements and pick the one being resized
|
||
// (it will have inline top/height from the resize composable)
|
||
const events = document.querySelectorAll('.fc-event');
|
||
for (const ev of events) {
|
||
const el = ev as HTMLElement;
|
||
if (el.style.top && el.style.height) {
|
||
return {
|
||
top: parseFloat(el.style.top),
|
||
height: parseFloat(el.style.height),
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
});
|
||
expect(previewStyle).not.toBeNull();
|
||
|
||
// With snap = 15 min and grid scale = 15 min, snapPx = slotHeight.
|
||
// The end edge (top + height) should be at a grid-aligned position
|
||
// (divisible by slotHeight), even though the event starts at 10:07
|
||
// which is NOT grid-aligned.
|
||
const previewEnd = previewStyle!.top + previewStyle!.height;
|
||
const endOffGrid = previewEnd % slotHeight;
|
||
expect(endOffGrid).toBeLessThanOrEqual(1); // allow sub-pixel rounding
|
||
|
||
// Release the mouse to complete the resize
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
page.mouse.up(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const endDate = new Date(body.data.end);
|
||
// The saved end time should be snapped to the 15-min grid
|
||
expect(endDate.getMinutes() % 15).toBe(0);
|
||
|
||
// After the API update, the rendered position should match the preview.
|
||
// The event top stays the same (only end edge was resized), so
|
||
// compare the final rendered height to the preview height.
|
||
await expect(async () => {
|
||
const updatedEvent = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Off-grid snap test' })
|
||
.first();
|
||
const updatedBox = await updatedEvent.boundingBox();
|
||
expect(updatedBox).not.toBeNull();
|
||
// Rendered height should match preview height within 2px
|
||
expect(Math.abs(updatedBox!.height - previewStyle!.height)).toBeLessThanOrEqual(2);
|
||
}).toPass({ timeout: 5000 });
|
||
});
|
||
|
||
test('resize top edge of off-grid event snaps to absolute grid positions', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Set snap interval to 15 min
|
||
await goToCalendar(page);
|
||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||
await page.getByLabel('Snap Interval').click();
|
||
await page.getByRole('option', { name: '15 min' }).click();
|
||
await page.keyboard.press('Escape');
|
||
|
||
// Create entry at 10:07–11:07 — deliberately off the 15-min grid
|
||
const start = todayAt(10, 7);
|
||
const end = todayAt(11, 7);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
description: 'Off-grid top snap test',
|
||
start,
|
||
end,
|
||
});
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
const event = page
|
||
.locator('.fc-event')
|
||
.filter({ hasText: 'Off-grid top snap test' })
|
||
.first();
|
||
await expect(event).toBeVisible();
|
||
|
||
const slotHeight = await getSlotHeight(page);
|
||
const eventBox = await event.boundingBox();
|
||
const topY = eventBox!.y;
|
||
const centerX = eventBox!.x + eventBox!.width / 2;
|
||
|
||
// Resize top edge up by ~2 slots (30 min)
|
||
await page.mouse.move(centerX, topY + 3);
|
||
await page.waitForTimeout(100);
|
||
await page.mouse.down();
|
||
await page.mouse.move(centerX, topY - slotHeight * 2, { steps: 15 });
|
||
|
||
// Capture preview position during drag
|
||
const previewStyle = await page.evaluate(() => {
|
||
const events = document.querySelectorAll('.fc-event');
|
||
for (const ev of events) {
|
||
const el = ev as HTMLElement;
|
||
if (el.style.top && el.style.height) {
|
||
return {
|
||
top: parseFloat(el.style.top),
|
||
height: parseFloat(el.style.height),
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
});
|
||
expect(previewStyle).not.toBeNull();
|
||
|
||
// The start edge (top) should be at a grid-aligned position
|
||
const topOffGrid = previewStyle!.top % slotHeight;
|
||
expect(topOffGrid).toBeLessThanOrEqual(1);
|
||
|
||
// Release the mouse
|
||
const [putResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
(r) =>
|
||
r.url().includes('/time-entries/') &&
|
||
r.request().method() === 'PUT' &&
|
||
r.status() === 200
|
||
),
|
||
page.mouse.up(),
|
||
]);
|
||
|
||
const body = await putResponse.json();
|
||
const startDate = new Date(body.data.start);
|
||
// The saved start time should be snapped to the 15-min grid
|
||
expect(startDate.getMinutes() % 15).toBe(0);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 18: Advanced Overlap Layout
|
||
// =============================================
|
||
|
||
test.describe('Advanced Overlap Layout', () => {
|
||
test('three overlapping events render in separate columns', async ({ page, ctx }) => {
|
||
const start = todayAt(10);
|
||
const end = todayAt(11);
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple A', start, end });
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple B', start, end });
|
||
await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple C', start, end });
|
||
await goToCalendar(page);
|
||
await scrollCalendarToTime(page, '09:00:00');
|
||
|
||
const eventA = page.locator('.fc-event').filter({ hasText: 'Triple A' }).first();
|
||
const eventB = page.locator('.fc-event').filter({ hasText: 'Triple B' }).first();
|
||
const eventC = page.locator('.fc-event').filter({ hasText: 'Triple C' }).first();
|
||
await expect(eventA).toBeVisible();
|
||
await expect(eventB).toBeVisible();
|
||
await expect(eventC).toBeVisible();
|
||
|
||
const boxA = await eventA.boundingBox();
|
||
const boxB = await eventB.boundingBox();
|
||
const boxC = await eventC.boundingBox();
|
||
expect(boxA).not.toBeNull();
|
||
expect(boxB).not.toBeNull();
|
||
expect(boxC).not.toBeNull();
|
||
|
||
// All three should have similar widths (each ~1/3 of column)
|
||
const widths = [boxA!.width, boxB!.width, boxC!.width].sort((a, b) => a - b);
|
||
// Smallest should be at least 60% of largest (they're roughly equal thirds)
|
||
expect(widths[0]).toBeGreaterThan(widths[2]! * 0.6);
|
||
|
||
// All three should be at distinct x positions
|
||
const xs = [boxA!.x, boxB!.x, boxC!.x].sort((a, b) => a - b);
|
||
// First and second should differ
|
||
expect(xs[1]! - xs[0]!).toBeGreaterThan(5);
|
||
// Second and third should differ
|
||
expect(xs[2]! - xs[1]!).toBeGreaterThan(5);
|
||
});
|
||
});
|
||
|
||
// =============================================
|
||
// Section 19: Daily Total Updates After Create
|
||
// =============================================
|
||
|
||
test.describe('Daily Total After Create', () => {
|
||
test('daily total updates after creating entry via drag-to-create', async ({ page }) => {
|
||
await goToCalendar(page);
|
||
|
||
const todayHeader = page.locator('.fc-day-today.fc-col-header-cell');
|
||
|
||
// Store initial total text
|
||
const initialText = await todayHeader.innerText();
|
||
|
||
await scrollCalendarToTime(page, '13:00:00');
|
||
|
||
// Get today's column X position to ensure drag is on the right day
|
||
const todayDate = await todayHeader.getAttribute('data-date');
|
||
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayDate}"]`);
|
||
const colBox = await todayCol.first().boundingBox();
|
||
const colCenterX = colBox!.x + colBox!.width / 2;
|
||
|
||
// Drag to create a 1-hour entry from 14:00 to 15:00
|
||
const slot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||
const slot15 = page.locator('.fc-timegrid-slot-lane[data-time="15:00:00"]').first();
|
||
const box14 = await slot14.boundingBox();
|
||
const box15 = await slot15.boundingBox();
|
||
|
||
await page.mouse.move(colCenterX, box14!.y + 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(colCenterX, box15!.y + 2, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Create modal should appear
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||
|
||
// Submit the create form
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes('/time-entries') &&
|
||
response.request().method() === 'POST' &&
|
||
response.status() === 201
|
||
),
|
||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||
]);
|
||
|
||
// Wait for the header total to update after refresh
|
||
await expect(async () => {
|
||
const updatedText = await todayHeader.innerText();
|
||
expect(updatedText).not.toBe(initialText);
|
||
}).toPass({ timeout: 5000 });
|
||
});
|
||
});
|
||
|
||
test('test that calendar context menu can add a break that fills the gap between two entries', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||
// Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const gapStart = `${today}T10:00:00Z`;
|
||
const gapEnd = `${today}T11:00:00Z`;
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: `${today}T09:00:00Z`,
|
||
end: gapStart,
|
||
description: 'Gap work A',
|
||
});
|
||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||
start: gapEnd,
|
||
end: `${today}T12:00:00Z`,
|
||
description: 'Gap work B',
|
||
});
|
||
|
||
await goToCalendar(page);
|
||
const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first();
|
||
await eventA.scrollIntoViewIfNeeded();
|
||
await expect(eventA).toBeVisible();
|
||
|
||
// Right-click just below entry A (inside the gap, in the same day column)
|
||
const box = await eventA.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, {
|
||
button: 'right',
|
||
});
|
||
await expect(page.getByRole('menu')).toBeVisible();
|
||
await page.getByRole('menuitem', { name: 'Add Break' }).click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
|
||
// The break is prefilled to fill the gap exactly
|
||
const [createResponse] = await Promise.all([
|
||
page.waitForResponse(
|
||
async (response) =>
|
||
response.url().includes('/time-entries') &&
|
||
response.request().method() === 'POST' &&
|
||
response.status() === 201 &&
|
||
(await response.json()).data.type === 'break'
|
||
),
|
||
page.getByRole('button', { name: 'Add Break' }).click(),
|
||
]);
|
||
const body = await createResponse.json();
|
||
expect(body.data.start).toBe(gapStart);
|
||
expect(body.data.end).toBe(gapEnd);
|
||
});
|