1474 lines
53 KiB
TypeScript
1474 lines
53 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
||
import { execFileSync } from "node:child_process";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { resolve } from "node:path";
|
||
|
||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||
const PLAYWRIGHT_RUNTIME_ENV_PATH = resolve(process.cwd(), "e2e/.playwright-runtime.json");
|
||
const WEB_ENV_PATHS = [
|
||
resolve(process.cwd(), ".env.local"),
|
||
resolve(process.cwd(), "../../.env.local"),
|
||
resolve(process.cwd(), "../../.env"),
|
||
];
|
||
|
||
function resolveDatabaseUrl(): string {
|
||
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
|
||
if (explicitPlaywrightUrl) {
|
||
return explicitPlaywrightUrl;
|
||
}
|
||
|
||
if (existsSync(PLAYWRIGHT_RUNTIME_ENV_PATH)) {
|
||
try {
|
||
const runtimeEnv = JSON.parse(readFileSync(PLAYWRIGHT_RUNTIME_ENV_PATH, "utf8")) as {
|
||
PLAYWRIGHT_DATABASE_URL?: string;
|
||
DATABASE_URL?: string;
|
||
};
|
||
if (runtimeEnv.PLAYWRIGHT_DATABASE_URL) {
|
||
return runtimeEnv.PLAYWRIGHT_DATABASE_URL;
|
||
}
|
||
if (runtimeEnv.DATABASE_URL) {
|
||
return runtimeEnv.DATABASE_URL;
|
||
}
|
||
} catch {
|
||
// Fall back to env files if the runtime file is temporarily unavailable.
|
||
}
|
||
}
|
||
|
||
for (const envPath of WEB_ENV_PATHS) {
|
||
if (!existsSync(envPath)) {
|
||
continue;
|
||
}
|
||
|
||
const envFile = readFileSync(envPath, "utf8");
|
||
for (const rawLine of envFile.split(/\r?\n/u)) {
|
||
const line = rawLine.trim();
|
||
if (!line || line.startsWith("#")) {
|
||
continue;
|
||
}
|
||
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
|
||
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
|
||
}
|
||
if (line.startsWith("DATABASE_URL_TEST=")) {
|
||
return line.slice("DATABASE_URL_TEST=".length);
|
||
}
|
||
if (line.startsWith("DATABASE_URL=")) {
|
||
return line.slice("DATABASE_URL=".length);
|
||
}
|
||
}
|
||
}
|
||
|
||
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
|
||
if (fallbackTestUrl) {
|
||
return fallbackTestUrl;
|
||
}
|
||
|
||
const fallback = process.env["DATABASE_URL"];
|
||
if (fallback) {
|
||
return fallback;
|
||
}
|
||
|
||
throw new Error("DATABASE_URL is not available for the Playwright timeline test.");
|
||
}
|
||
|
||
function runDbJson<T>(body: string): T {
|
||
const script = `
|
||
import { PrismaClient } from "@prisma/client";
|
||
const prisma = new PrismaClient({
|
||
datasources: {
|
||
db: {
|
||
url: process.env.DATABASE_URL,
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
${body}
|
||
} finally {
|
||
await prisma.$disconnect();
|
||
}
|
||
`;
|
||
|
||
const output = execFileSync("node", ["--input-type=module", "-e", script], {
|
||
cwd: DB_WORKDIR,
|
||
env: {
|
||
...process.env,
|
||
DATABASE_URL: resolveDatabaseUrl(),
|
||
},
|
||
encoding: "utf8",
|
||
}).trim();
|
||
|
||
return JSON.parse(output) as T;
|
||
}
|
||
|
||
type TimelineSegmentScenario = {
|
||
assignmentId: string;
|
||
projectId: string;
|
||
projectName: string;
|
||
projectShortCode: string;
|
||
resourceId: string;
|
||
resourceEid: string;
|
||
};
|
||
|
||
function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario {
|
||
return runDbJson<TimelineSegmentScenario>(`
|
||
const availability = {
|
||
monday: 8,
|
||
tuesday: 8,
|
||
wednesday: 8,
|
||
thursday: 8,
|
||
friday: 8,
|
||
saturday: 0,
|
||
sunday: 0,
|
||
};
|
||
|
||
const resource = await prisma.resource.create({
|
||
data: {
|
||
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
||
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
||
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)},
|
||
chapter: "E2E",
|
||
lcrCents: 5000,
|
||
ucrCents: 9000,
|
||
availability,
|
||
skills: [],
|
||
dynamicFields: {},
|
||
resourceType: "EMPLOYEE",
|
||
chgResponsibility: true,
|
||
rolledOff: false,
|
||
departed: false,
|
||
fte: 1,
|
||
},
|
||
select: { id: true, eid: true },
|
||
});
|
||
|
||
const project = await prisma.project.create({
|
||
data: {
|
||
shortCode: ${JSON.stringify(`E2ETL${suffix.slice(-6).toUpperCase()}`)},
|
||
name: ${JSON.stringify(`E2E Timeline Project ${suffix}`)},
|
||
orderType: "CHARGEABLE",
|
||
allocationType: "EXT",
|
||
budgetCents: 2500000,
|
||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||
status: "ACTIVE",
|
||
staffingReqs: [],
|
||
dynamicFields: {},
|
||
},
|
||
select: { id: true, name: true, shortCode: true },
|
||
});
|
||
|
||
const assignment = await prisma.assignment.create({
|
||
data: {
|
||
resourceId: resource.id,
|
||
projectId: project.id,
|
||
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||
hoursPerDay: 8,
|
||
percentage: 100,
|
||
role: "E2E Role",
|
||
dailyCostCents: 40000,
|
||
status: "ACTIVE",
|
||
metadata: {},
|
||
},
|
||
select: { id: true },
|
||
});
|
||
|
||
console.log(JSON.stringify({
|
||
assignmentId: assignment.id,
|
||
projectId: project.id,
|
||
projectName: project.name,
|
||
projectShortCode: project.shortCode,
|
||
resourceId: resource.id,
|
||
resourceEid: resource.eid,
|
||
}));
|
||
`);
|
||
}
|
||
|
||
function cleanupTimelineSegmentScenario(projectId: string, resourceId: string) {
|
||
runDbJson<null>(`
|
||
await prisma.assignment.deleteMany({
|
||
where: { projectId: ${JSON.stringify(projectId)} },
|
||
});
|
||
|
||
await prisma.project.deleteMany({
|
||
where: { id: ${JSON.stringify(projectId)} },
|
||
});
|
||
|
||
await prisma.resource.deleteMany({
|
||
where: { id: ${JSON.stringify(resourceId)} },
|
||
});
|
||
|
||
console.log("null");
|
||
`);
|
||
}
|
||
|
||
function listScenarioAssignments(projectId: string) {
|
||
return runDbJson<Array<{ id: string; startDate: string; endDate: string }>>(`
|
||
const assignments = await prisma.assignment.findMany({
|
||
where: { projectId: ${JSON.stringify(projectId)} },
|
||
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
||
select: { id: true, startDate: true, endDate: true },
|
||
});
|
||
|
||
console.log(JSON.stringify(
|
||
assignments.map((entry) => ({
|
||
id: entry.id,
|
||
startDate: entry.startDate.toISOString().slice(0, 10),
|
||
endDate: entry.endDate.toISOString().slice(0, 10),
|
||
})),
|
||
));
|
||
`);
|
||
}
|
||
|
||
function readScenarioSnapshot(projectId: string, resourceId: string, resourceEid: string) {
|
||
return runDbJson<{
|
||
resource: { id: string; eid: string; displayName: string } | null;
|
||
project: { id: string; name: string; shortCode: string } | null;
|
||
assignments: Array<{ id: string; startDate: string; endDate: string; status: string }>;
|
||
}>(`
|
||
const [resource, project, assignments] = await Promise.all([
|
||
prisma.resource.findUnique({
|
||
where: { id: ${JSON.stringify(resourceId)} },
|
||
select: { id: true, eid: true, displayName: true },
|
||
}),
|
||
prisma.project.findUnique({
|
||
where: { id: ${JSON.stringify(projectId)} },
|
||
select: { id: true, name: true, shortCode: true },
|
||
}),
|
||
prisma.assignment.findMany({
|
||
where: {
|
||
projectId: ${JSON.stringify(projectId)},
|
||
resourceId: ${JSON.stringify(resourceId)},
|
||
},
|
||
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
||
select: { id: true, startDate: true, endDate: true, status: true },
|
||
}),
|
||
]);
|
||
|
||
console.log(JSON.stringify({
|
||
resource: resource && resource.eid === ${JSON.stringify(resourceEid)}
|
||
? resource
|
||
: resource,
|
||
project,
|
||
assignments: assignments.map((entry) => ({
|
||
id: entry.id,
|
||
startDate: entry.startDate.toISOString().slice(0, 10),
|
||
endDate: entry.endDate.toISOString().slice(0, 10),
|
||
status: entry.status,
|
||
})),
|
||
}));
|
||
`);
|
||
}
|
||
|
||
async function waitForScenarioAssignments(
|
||
projectId: string,
|
||
expected: Array<{ startDate: string; endDate: string }>,
|
||
) {
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
listScenarioAssignments(projectId).map((entry) => ({
|
||
startDate: entry.startDate,
|
||
endDate: entry.endDate,
|
||
})),
|
||
{ timeout: 15000 },
|
||
)
|
||
.toEqual(expected);
|
||
}
|
||
|
||
async function dragLocatorBy(page: Page, locator: ReturnType<Page["locator"]>, deltaX: number) {
|
||
const box = await locator.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected locator to be visible before dragging");
|
||
}
|
||
|
||
const startX = box.x + box.width / 2;
|
||
const startY = box.y + box.height / 2;
|
||
await page.mouse.move(startX, startY);
|
||
await page.mouse.down();
|
||
await page.mouse.move(startX + deltaX, startY, { steps: 8 });
|
||
await page.waitForTimeout(80);
|
||
|
||
return box;
|
||
}
|
||
|
||
async function openAllocationContextMenuAtOffset(
|
||
page: Page,
|
||
locator: ReturnType<Page["locator"]>,
|
||
xOffset: number,
|
||
) {
|
||
const box = await locator.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected allocation segment to be visible before context click");
|
||
}
|
||
|
||
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
|
||
}
|
||
|
||
async function releaseMouse(page: Page) {
|
||
await page.mouse.up();
|
||
await page.waitForTimeout(120);
|
||
}
|
||
|
||
async function selectProjectFromCombobox(
|
||
page: Page,
|
||
projectShortCode: string,
|
||
projectName: string,
|
||
) {
|
||
const projectInput = page.locator('input[placeholder="Search project…"]').last();
|
||
await projectInput.click();
|
||
await projectInput.fill(projectShortCode);
|
||
|
||
const optionName = new RegExp(
|
||
`${escapeRegex(projectShortCode)}\\s*.*${escapeRegex(projectName)}`,
|
||
"i",
|
||
);
|
||
await page.getByRole("button", { name: optionName }).last().click();
|
||
}
|
||
|
||
async function dragRowSelection(
|
||
page: Page,
|
||
row: ReturnType<Page["locator"]>,
|
||
startX: number,
|
||
endX: number,
|
||
) {
|
||
const rowBox = await readBoundingBox(row);
|
||
const centerY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6);
|
||
await page.mouse.move(startX, centerY);
|
||
await page.mouse.down();
|
||
await page.mouse.move(endX, centerY, { steps: 8 });
|
||
await page.mouse.up();
|
||
await page.waitForTimeout(120);
|
||
}
|
||
|
||
async function readBoundingBox(locator: ReturnType<Page["locator"]>) {
|
||
const box = await locator.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected locator to have a bounding box");
|
||
}
|
||
return box;
|
||
}
|
||
|
||
async function listRenderedAllocationSegments(
|
||
row: ReturnType<Page["locator"]>,
|
||
allocationId?: string,
|
||
) {
|
||
const selector = allocationId
|
||
? `[data-allocation-id="${allocationId}"]`
|
||
: "[data-allocation-id]";
|
||
return row.locator(selector).evaluateAll((elements) =>
|
||
elements.map((element) => {
|
||
const htmlElement = element as HTMLElement;
|
||
const { dataset } = htmlElement;
|
||
return {
|
||
allocationId: dataset.allocationId ?? null,
|
||
segmentIndex: dataset.allocationSegmentIndex ?? null,
|
||
segmentStart: dataset.allocationSegmentStart ?? null,
|
||
segmentEnd: dataset.allocationSegmentEnd ?? null,
|
||
interaction: dataset.allocationInteraction ?? null,
|
||
text: htmlElement.innerText.replace(/\s+/gu, " ").trim(),
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
function escapeRegex(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
async function signInAsAdmin(page: Page) {
|
||
await page.goto("/auth/signin");
|
||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||
await page.fill('input[type="password"]', "admin123");
|
||
await page.click('button[type="submit"]');
|
||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||
}
|
||
|
||
async function findVisibleTimelineEntryId(
|
||
page: Page,
|
||
selector: string,
|
||
minimumWidth = 24,
|
||
) {
|
||
return page.locator(selector).evaluateAll((elements, minimum) => {
|
||
for (const element of elements) {
|
||
if (!(element instanceof HTMLElement)) continue;
|
||
const id = element.dataset.allocationId ?? element.dataset.timelineProjectId;
|
||
if (!id) continue;
|
||
|
||
const rect = element.getBoundingClientRect();
|
||
const mostlyVisible =
|
||
rect.width >= minimum &&
|
||
rect.height >= 12 &&
|
||
rect.right > 96 &&
|
||
rect.left < window.innerWidth - 96 &&
|
||
rect.bottom > 0 &&
|
||
rect.top < window.innerHeight;
|
||
|
||
if (mostlyVisible) {
|
||
return id;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}, minimumWidth);
|
||
}
|
||
|
||
async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||
return page.locator(selector).evaluateAll((elements) => {
|
||
const candidates: Array<{ id: string; score: number }> = [];
|
||
let fallbackId: string | null = null;
|
||
|
||
for (const element of elements) {
|
||
if (!(element instanceof HTMLElement)) continue;
|
||
const id = element.dataset.allocationId;
|
||
if (!id) continue;
|
||
|
||
const rect = element.getBoundingClientRect();
|
||
const isVisible =
|
||
rect.width >= 24 &&
|
||
rect.height >= 10 &&
|
||
rect.right > 48 &&
|
||
rect.left < window.innerWidth - 48 &&
|
||
rect.bottom > 0 &&
|
||
rect.top < window.innerHeight;
|
||
|
||
if (!isVisible) {
|
||
continue;
|
||
}
|
||
|
||
fallbackId ??= id;
|
||
|
||
const isPreferred =
|
||
rect.width >= 36 &&
|
||
rect.width <= 220 &&
|
||
rect.left >= 48 &&
|
||
rect.right <= window.innerWidth - 48;
|
||
|
||
if (!isPreferred) {
|
||
continue;
|
||
}
|
||
|
||
const centerX = rect.left + rect.width / 2;
|
||
const widthPenalty = Math.abs(rect.width - 96);
|
||
const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4;
|
||
candidates.push({ id, score: widthPenalty + centerPenalty });
|
||
}
|
||
|
||
candidates.sort((a, b) => a.score - b.score);
|
||
return candidates[0]?.id ?? fallbackId;
|
||
});
|
||
}
|
||
|
||
async function measureProjectBarDragGap(page: Page, locatorString: string) {
|
||
const bar = page.locator(locatorString);
|
||
await bar.scrollIntoViewIfNeeded();
|
||
|
||
const box = await bar.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected a visible project bar for drag measurement");
|
||
}
|
||
|
||
const anchorOffsetX = Math.min(Math.max(box.width * 0.35, 16), box.width - 16);
|
||
const pointerY = box.y + box.height / 2;
|
||
const startX = box.x + anchorOffsetX;
|
||
const dragDistance = 132;
|
||
const gaps: number[] = [];
|
||
const lefts: number[] = [];
|
||
|
||
await page.mouse.move(startX, pointerY);
|
||
await page.mouse.down();
|
||
|
||
for (let step = 1; step <= 8; step += 1) {
|
||
const targetX = startX + (dragDistance * step) / 8;
|
||
await page.mouse.move(targetX, pointerY, { steps: 1 });
|
||
await page.waitForTimeout(16);
|
||
|
||
const currentBox = await bar.boundingBox();
|
||
if (!currentBox) continue;
|
||
|
||
lefts.push(currentBox.x);
|
||
gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX)));
|
||
}
|
||
|
||
await page.mouse.up();
|
||
|
||
return {
|
||
maxGap: Math.max(...gaps),
|
||
movedDistance: Math.max(...lefts) - box.x,
|
||
};
|
||
}
|
||
|
||
async function measureAllocationDragGap(page: Page, locatorString: string) {
|
||
const bar = page.locator(locatorString);
|
||
await bar.scrollIntoViewIfNeeded();
|
||
|
||
const box = await bar.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected a visible allocation bar for drag measurement");
|
||
}
|
||
|
||
const anchorOffsetX = Math.min(Math.max(box.width / 2, 18), box.width - 18);
|
||
const pointerY = box.y + box.height / 2;
|
||
const startX = box.x + anchorOffsetX;
|
||
const dragDistance = 108;
|
||
const gaps: number[] = [];
|
||
const lefts: number[] = [];
|
||
|
||
await page.mouse.move(startX, pointerY);
|
||
await page.mouse.down();
|
||
|
||
for (let step = 1; step <= 8; step += 1) {
|
||
const targetX = startX + (dragDistance * step) / 8;
|
||
await page.mouse.move(targetX, pointerY, { steps: 1 });
|
||
await page.waitForTimeout(16);
|
||
|
||
const currentBox = await bar.boundingBox();
|
||
if (!currentBox) continue;
|
||
|
||
lefts.push(currentBox.x);
|
||
gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX)));
|
||
}
|
||
|
||
await page.mouse.up();
|
||
|
||
return {
|
||
maxGap: Math.max(...gaps),
|
||
movedDistance: Math.max(...lefts) - box.x,
|
||
};
|
||
}
|
||
|
||
async function measureAllocationResizeGap(page: Page, locatorString: string) {
|
||
const bar = page.locator(locatorString);
|
||
await bar.scrollIntoViewIfNeeded();
|
||
|
||
const box = await bar.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected a visible allocation bar for resize measurement");
|
||
}
|
||
|
||
const pointerY = box.y + box.height / 2;
|
||
const startX = box.x + box.width - 3;
|
||
const resizeDistance = 96;
|
||
const widths: number[] = [];
|
||
const rightEdges: number[] = [];
|
||
|
||
await page.mouse.move(startX, pointerY);
|
||
await page.mouse.down();
|
||
|
||
for (let step = 1; step <= 8; step += 1) {
|
||
const targetX = startX + (resizeDistance * step) / 8;
|
||
await page.mouse.move(targetX, pointerY, { steps: 1 });
|
||
await page.waitForTimeout(16);
|
||
|
||
const currentBox = await bar.boundingBox();
|
||
if (!currentBox) continue;
|
||
|
||
widths.push(currentBox.width);
|
||
rightEdges.push(currentBox.x + currentBox.width);
|
||
}
|
||
|
||
await page.mouse.up();
|
||
|
||
return {
|
||
widthGain: Math.max(...widths) - box.width,
|
||
rightEdgeGain: Math.max(...rightEdges) - (box.x + box.width),
|
||
};
|
||
}
|
||
|
||
async function measureAllocationResizeStartGap(page: Page, locatorString: string) {
|
||
const bar = page.locator(locatorString);
|
||
await bar.scrollIntoViewIfNeeded();
|
||
|
||
const box = await bar.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
throw new Error("Expected a visible allocation bar for resize-start measurement");
|
||
}
|
||
|
||
const pointerY = box.y + box.height / 2;
|
||
const startX = box.x + 3;
|
||
const resizeDistance = 72;
|
||
const widths: number[] = [];
|
||
const leftEdges: number[] = [];
|
||
|
||
await page.mouse.move(startX, pointerY);
|
||
await page.mouse.down();
|
||
|
||
for (let step = 1; step <= 8; step += 1) {
|
||
const targetX = startX - (resizeDistance * step) / 8;
|
||
await page.mouse.move(targetX, pointerY, { steps: 1 });
|
||
await page.waitForTimeout(16);
|
||
|
||
const currentBox = await bar.boundingBox();
|
||
if (!currentBox) continue;
|
||
|
||
widths.push(currentBox.width);
|
||
leftEdges.push(currentBox.x);
|
||
}
|
||
|
||
await page.mouse.up();
|
||
|
||
return {
|
||
widthGain: Math.max(...widths) - box.width,
|
||
leftEdgeGain: box.x - Math.min(...leftEdges),
|
||
};
|
||
}
|
||
|
||
test.describe("Timeline", () => {
|
||
test.describe.configure({ mode: "serial" });
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||
});
|
||
await signInAsAdmin(page);
|
||
await page.goto("/timeline");
|
||
});
|
||
|
||
test("loads and displays the timeline", async ({ page }) => {
|
||
await expect(page.locator("text=Resource view")).toBeVisible();
|
||
await expect(page.locator("text=Project view")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: /All Clients|Clients:/ })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: /All Chapters|Chapters:/ })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: /All people|People:/ })).toBeVisible();
|
||
// Timeline canvas should be visible
|
||
await expect(page.locator("div.app-surface.relative.flex-1.overflow-auto")).toBeVisible();
|
||
});
|
||
|
||
test("can switch between resource and project view", async ({ page }) => {
|
||
await page.click("text=Project view");
|
||
await expect(
|
||
page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/")),
|
||
).toBeVisible();
|
||
await page.click("text=Resource view");
|
||
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
||
});
|
||
|
||
test("can navigate forward and back", async ({ page }) => {
|
||
const todayBtn = page.locator("button", { hasText: "Today" });
|
||
await expect(todayBtn).toBeVisible();
|
||
await page.locator("button", { hasText: "›" }).click();
|
||
await page.locator("button", { hasText: "‹" }).click();
|
||
await todayBtn.click();
|
||
});
|
||
|
||
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
||
await page.goto("/allocations");
|
||
await expect(
|
||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||
).toBeVisible({ timeout: 10000 });
|
||
|
||
await page.locator('nav a >> text="Timeline"').first().click();
|
||
await expect(page).toHaveURL(/\/timeline/);
|
||
await expect(
|
||
page.locator(".app-toolbar").getByText(/[1-9]\d* resources · [1-9]\d* allocations/),
|
||
).toBeVisible({ timeout: 10000 });
|
||
await expect(page.getByTestId("timeline-resource-row-canvas").first()).toBeVisible({
|
||
timeout: 10000,
|
||
});
|
||
await expect(page.locator("text=No allocations in this time range")).not.toBeVisible();
|
||
});
|
||
|
||
test("filter panel opens and closes", async ({ page }) => {
|
||
await page.locator("button", { hasText: "Filter" }).click();
|
||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||
await expect(page.getByPlaceholder("Search projects…")).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByRole("heading", { name: "Filters" })).not.toBeVisible();
|
||
});
|
||
|
||
test("shows placeholder bars for unassigned allocations", async ({ page }) => {
|
||
// Filter to show placeholders (enabled by default)
|
||
// The timeline should have at least one dashed placeholder bar from seed data
|
||
await page.waitForSelector(".overflow-auto", { state: "visible" });
|
||
// Check that the timeline loaded (resource rows or empty state visible)
|
||
await expect(
|
||
page.locator(".app-toolbar").getByText(/\d+ resources · \d+ allocations/),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("clicking a placeholder opens the fill placeholder modal", async ({ page }) => {
|
||
// Wait for timeline to load
|
||
await page.waitForSelector(".overflow-auto");
|
||
await page.waitForTimeout(1000); // let tRPC queries settle
|
||
|
||
// Try to find and click a placeholder bar (dashed border style)
|
||
const placeholderBar = page.locator("[style*='dashed']").first();
|
||
if ((await placeholderBar.count()) > 0) {
|
||
await placeholderBar.click();
|
||
await expect(
|
||
page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource")),
|
||
).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
}
|
||
});
|
||
|
||
test("resource and project views keep tooltips opaque in dark mode and support right click", async ({
|
||
page,
|
||
}) => {
|
||
await page.waitForSelector(".overflow-auto", { state: "visible" });
|
||
await page.waitForTimeout(1000);
|
||
|
||
const heatmapTooltip = page
|
||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-gray-800")
|
||
.first();
|
||
const allocationPopoverField = page.getByText("Hours / day");
|
||
|
||
const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
|
||
const resourceHoverBox = await resourceHoverTarget.boundingBox();
|
||
expect(resourceHoverBox).not.toBeNull();
|
||
if (!resourceHoverBox) {
|
||
throw new Error("Expected a resource timeline row canvas to be available");
|
||
}
|
||
await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20);
|
||
await expect(heatmapTooltip).toBeVisible();
|
||
await expect
|
||
.poll(async () => {
|
||
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||
})
|
||
.toBe("rgba(3, 7, 18, 0.96)");
|
||
|
||
const resourceAllocation = page
|
||
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
|
||
.first();
|
||
await resourceAllocation.click({ button: "right" });
|
||
await expect(allocationPopoverField).toBeVisible();
|
||
await page.mouse.click(40, 40);
|
||
|
||
await page.getByText("Project view").click();
|
||
await expect(page.getByText(/projects/)).toBeVisible();
|
||
await page.waitForTimeout(500);
|
||
|
||
const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
|
||
const projectHoverBox = await projectHoverTarget.boundingBox();
|
||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||
const projectAllocationBox = await projectAllocation.boundingBox();
|
||
expect(projectHoverBox).not.toBeNull();
|
||
expect(projectAllocationBox).not.toBeNull();
|
||
if (!projectHoverBox) {
|
||
throw new Error("Expected a project timeline row canvas to be available");
|
||
}
|
||
if (!projectAllocationBox) {
|
||
throw new Error("Expected a project allocation block to be available");
|
||
}
|
||
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||
await expect(heatmapTooltip).toBeVisible();
|
||
await expect
|
||
.poll(async () => {
|
||
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||
})
|
||
.toBe("rgba(3, 7, 18, 0.96)");
|
||
|
||
await projectAllocation.click({ button: "right" });
|
||
await expect(allocationPopoverField).toBeVisible();
|
||
});
|
||
|
||
test("resource view right click resolves allocation popovers instead of getting stuck on loading", async ({
|
||
page,
|
||
}) => {
|
||
await page.waitForSelector(".overflow-auto", { state: "visible" });
|
||
await expect(
|
||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||
).toBeVisible();
|
||
|
||
const allocation = page
|
||
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
|
||
.first();
|
||
await allocation.click({ button: "right" });
|
||
|
||
await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 });
|
||
await expect(page.getByText("Hours / day")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||
});
|
||
|
||
test("right clicking a project header strip opens the project panel", async ({ page }) => {
|
||
await page.getByText("Project view").click();
|
||
await expect(page.getByText(/projects/)).toBeVisible();
|
||
|
||
const projectBar = page
|
||
.locator("[data-timeline-entry-type='project-bar'][data-timeline-project-id]")
|
||
.first();
|
||
await expect(projectBar).toBeVisible();
|
||
|
||
await projectBar.click({ button: "right" });
|
||
await expect(page.getByText("Project Details")).toBeVisible();
|
||
await expect(page.getByRole("heading", { level: 2 })).toBeVisible();
|
||
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByText("Project Details")).not.toBeVisible();
|
||
});
|
||
|
||
test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||
await expect(row).toBeVisible();
|
||
|
||
const holidayBlock = row.locator(
|
||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||
).first();
|
||
await expect(holidayBlock).toBeVisible();
|
||
|
||
const rowBox = await row.boundingBox();
|
||
const holidayBox = await holidayBlock.boundingBox();
|
||
expect(rowBox).not.toBeNull();
|
||
expect(holidayBox).not.toBeNull();
|
||
|
||
if (!rowBox || !holidayBox) {
|
||
throw new Error("Expected timeline row and holiday block bounding boxes to be available");
|
||
}
|
||
|
||
await row.hover({
|
||
position: {
|
||
x: holidayBox.x - rowBox.x + holidayBox.width / 2,
|
||
y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4),
|
||
},
|
||
});
|
||
|
||
const holidayTooltip = page
|
||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||
.first();
|
||
|
||
await expect(holidayTooltip).toBeVisible();
|
||
await expect(holidayTooltip).toContainText("Karfreitag");
|
||
await expect(holidayTooltip).toContainText("3 April 2026");
|
||
});
|
||
|
||
test("allocation context popover stays interactive after pointer moves across the timeline canvas", async ({
|
||
page,
|
||
}) => {
|
||
await expect(
|
||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||
).toBeVisible();
|
||
|
||
const allocationId = await findVisibleAllocationIdForResize(
|
||
page,
|
||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||
);
|
||
expect(allocationId).toBeTruthy();
|
||
|
||
const allocation = page.locator(
|
||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||
);
|
||
await allocation.click({ button: "right" });
|
||
|
||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||
await expect(cancelButton).toBeVisible();
|
||
|
||
const resourceRow = page.getByTestId("timeline-resource-row-canvas").first();
|
||
const resourceRowBox = await resourceRow.boundingBox();
|
||
expect(resourceRowBox).not.toBeNull();
|
||
if (!resourceRowBox) {
|
||
throw new Error("Expected a resource timeline row canvas to be available");
|
||
}
|
||
|
||
await page.mouse.move(resourceRowBox.x + 160, resourceRowBox.y + 20);
|
||
await expect(cancelButton).toBeVisible();
|
||
|
||
await cancelButton.click();
|
||
await expect(cancelButton).not.toBeVisible();
|
||
});
|
||
|
||
test("timeline overlays react predictably to viewport changes", async ({ page }) => {
|
||
const scrollContainer = page.locator("div.app-surface.relative.z-0.flex-1.overflow-auto");
|
||
await expect(scrollContainer).toBeVisible();
|
||
|
||
const resourceLabel = page.locator("[data-resource-hover-id]").first();
|
||
await resourceLabel.hover();
|
||
|
||
const hoverCard = page.locator("[data-resource-hover-card='true']");
|
||
await expect(hoverCard).toBeVisible();
|
||
|
||
const initialTop = await hoverCard.evaluate((element) => element.getBoundingClientRect().top);
|
||
|
||
await scrollContainer.evaluate((element) => {
|
||
element.scrollTop = Math.min(220, element.scrollHeight);
|
||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||
});
|
||
|
||
await expect(hoverCard).toBeVisible();
|
||
await expect
|
||
.poll(async () => hoverCard.evaluate((element) => element.getBoundingClientRect().top))
|
||
.not.toBe(initialTop);
|
||
|
||
await page.mouse.move(8, 8);
|
||
await expect(hoverCard).not.toBeVisible();
|
||
|
||
const allocationId = await findVisibleAllocationIdForResize(
|
||
page,
|
||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||
);
|
||
expect(allocationId).toBeTruthy();
|
||
|
||
const allocation = page.locator(
|
||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||
);
|
||
await allocation.click({ button: "right" });
|
||
|
||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||
await expect(cancelButton).toBeVisible();
|
||
|
||
await scrollContainer.evaluate((element) => {
|
||
element.scrollTop = Math.min(element.scrollTop + 160, element.scrollHeight);
|
||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||
});
|
||
|
||
await expect(cancelButton).not.toBeVisible();
|
||
});
|
||
|
||
test("project bars stay attached to the pointer during fast drag", async ({ page }) => {
|
||
await page.getByText("Project view").click();
|
||
await expect(page.getByText(/projects/)).toBeVisible();
|
||
await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible();
|
||
|
||
const projectId = await findVisibleTimelineEntryId(
|
||
page,
|
||
"[data-timeline-entry-type='project-bar'][data-timeline-project-id]",
|
||
24,
|
||
);
|
||
expect(projectId).toBeTruthy();
|
||
|
||
const result = await measureProjectBarDragGap(
|
||
page,
|
||
`[data-timeline-entry-type='project-bar'][data-timeline-project-id='${projectId}']`,
|
||
);
|
||
|
||
expect(result.movedDistance).toBeGreaterThan(72);
|
||
expect(result.maxGap).toBeLessThan(24);
|
||
});
|
||
|
||
test("resource allocation bars stay attached to the pointer during drag", async ({ page }) => {
|
||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await expect(
|
||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||
).toBeVisible();
|
||
|
||
const allocationId = await findVisibleAllocationIdForResize(
|
||
page,
|
||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||
);
|
||
expect(allocationId).toBeTruthy();
|
||
|
||
const result = await measureAllocationDragGap(
|
||
page,
|
||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||
);
|
||
|
||
expect(result.movedDistance).toBeGreaterThan(56);
|
||
expect(result.maxGap).toBeLessThan(24);
|
||
});
|
||
|
||
test("allocation resize shows a live preview before mouseup", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await expect(
|
||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||
).toBeVisible();
|
||
|
||
const allocationId = await findVisibleAllocationIdForResize(
|
||
page,
|
||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||
);
|
||
expect(allocationId).toBeTruthy();
|
||
|
||
const result = await measureAllocationResizeGap(
|
||
page,
|
||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||
);
|
||
|
||
expect(result.widthGain).toBeGreaterThan(64);
|
||
expect(result.rightEdgeGain).toBeGreaterThan(48);
|
||
});
|
||
|
||
test("allocation start resize shows a live preview before mouseup", async ({
|
||
page,
|
||
}) => {
|
||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await expect(
|
||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||
).toBeVisible();
|
||
|
||
const allocationId = await findVisibleAllocationIdForResize(
|
||
page,
|
||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||
);
|
||
expect(allocationId).toBeTruthy();
|
||
|
||
const result = await measureAllocationResizeStartGap(
|
||
page,
|
||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||
);
|
||
|
||
expect(result.widthGain).toBeGreaterThan(48);
|
||
expect(result.leftEdgeGain).toBeGreaterThan(36);
|
||
});
|
||
|
||
test("resource timeline supports resizing, moving, carving, and recreating segmented allocations with persisted dates", async ({
|
||
page,
|
||
}) => {
|
||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||
const scenario = createTimelineSegmentScenario(suffix);
|
||
const scenarioSnapshot = readScenarioSnapshot(
|
||
scenario.projectId,
|
||
scenario.resourceId,
|
||
scenario.resourceEid,
|
||
);
|
||
|
||
try {
|
||
expect(scenarioSnapshot.resource?.eid).toBe(scenario.resourceEid);
|
||
expect(scenarioSnapshot.project?.id).toBe(scenario.projectId);
|
||
expect(scenarioSnapshot.assignments).toEqual([
|
||
{
|
||
id: scenario.assignmentId,
|
||
startDate: "2026-04-06",
|
||
endDate: "2026-04-17",
|
||
status: "ACTIVE",
|
||
},
|
||
]);
|
||
|
||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
const row = page
|
||
.locator(
|
||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||
)
|
||
.first();
|
||
await expect(row).toBeVisible();
|
||
|
||
const renderedSegments = await listRenderedAllocationSegments(row, scenario.assignmentId);
|
||
console.log(
|
||
`[timeline-e2e] rendered segments ${JSON.stringify({
|
||
resourceEid: scenario.resourceEid,
|
||
assignmentId: scenario.assignmentId,
|
||
renderedSegments,
|
||
})}`,
|
||
);
|
||
|
||
const baseSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(baseSegment).toBeVisible();
|
||
await expect(baseSegment.locator('[data-allocation-handle="start"]')).toBeVisible();
|
||
await expect(baseSegment.locator('[data-allocation-handle="end"]')).toBeVisible();
|
||
|
||
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||
expect(dayWidth).toBeGreaterThan(8);
|
||
|
||
await dragLocatorBy(page, baseSegment.locator('[data-allocation-handle="start"]'), dayWidth);
|
||
await releaseMouse(page);
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-07", endDate: "2026-04-10" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const resizedSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-07"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(resizedSegment).toBeVisible();
|
||
|
||
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
|
||
await releaseMouse(page);
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-09" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const movedSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-09"]`,
|
||
);
|
||
await expect(movedSegment).toBeVisible();
|
||
|
||
await openAllocationContextMenuAtOffset(page, movedSegment, dayWidth * 1.5);
|
||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||
await expect(cancelButton).toBeVisible();
|
||
await cancelButton.click();
|
||
await expect(cancelButton).not.toBeVisible();
|
||
|
||
await dragLocatorBy(page, movedSegment.locator('[data-allocation-handle="end"]'), dayWidth);
|
||
await releaseMouse(page);
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-10" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const restoredSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(restoredSegment).toBeVisible();
|
||
|
||
await openAllocationContextMenuAtOffset(page, restoredSegment, dayWidth * 2.5);
|
||
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||
await expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026");
|
||
await expect(carveDateInputs.nth(3)).toHaveValue("08/04/2026");
|
||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-10" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||
await expect(leftSplit).toBeVisible();
|
||
await expect(rightSplit).toBeVisible();
|
||
await expect(nextWeekSegment).toBeVisible();
|
||
|
||
const leftSplitBox = await readBoundingBox(leftSplit);
|
||
const rightSplitBox = await readBoundingBox(rightSplit);
|
||
const rowBox = await readBoundingBox(row);
|
||
const gapCenterX = (leftSplitBox.x + leftSplitBox.width + rightSplitBox.x) / 2;
|
||
const gapCenterY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6);
|
||
|
||
await page.mouse.move(gapCenterX, gapCenterY);
|
||
await page.mouse.down();
|
||
await page.mouse.up();
|
||
|
||
await expect(page.getByText("Assign to Project")).toBeVisible();
|
||
|
||
await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName);
|
||
await page.getByRole("button", { name: "Assign" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-08", endDate: "2026-04-08" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-10" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||
).toBeVisible();
|
||
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(row).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||
).toBeVisible();
|
||
} finally {
|
||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||
}
|
||
});
|
||
|
||
test("resource timeline persists multi-day carve and reverse recreation after reload", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
|
||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||
const scenario = createTimelineSegmentScenario(suffix);
|
||
|
||
try {
|
||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
const row = page
|
||
.locator(
|
||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||
)
|
||
.first();
|
||
await expect(row).toBeVisible();
|
||
|
||
const baseSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(baseSegment).toBeVisible();
|
||
|
||
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||
expect(dayWidth).toBeGreaterThan(8);
|
||
|
||
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
|
||
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||
await carveDateInputs.nth(2).fill("08/04/2026");
|
||
await carveDateInputs.nth(3).fill("09/04/2026");
|
||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-10", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(row).toBeVisible();
|
||
|
||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first();
|
||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||
await expect(leftSplit).toBeVisible();
|
||
await expect(fridayBridge).toBeVisible();
|
||
await expect(mondaySegment).toBeVisible();
|
||
|
||
const fridayBridgeBox = await readBoundingBox(fridayBridge);
|
||
await dragRowSelection(
|
||
page,
|
||
row,
|
||
fridayBridgeBox.x - dayWidth / 2,
|
||
fridayBridgeBox.x - dayWidth * 1.5,
|
||
);
|
||
|
||
await expect(page.getByText("Assign to Project")).toBeVisible();
|
||
await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName);
|
||
await page.getByRole("button", { name: "Assign" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-08", endDate: "2026-04-09" },
|
||
{ startDate: "2026-04-10", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(row).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||
).toBeVisible();
|
||
} finally {
|
||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||
}
|
||
});
|
||
|
||
test("resource timeline narrow split fragments keep both handles and monday context dates", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
|
||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||
const scenario = createTimelineSegmentScenario(suffix);
|
||
|
||
try {
|
||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
const row = page
|
||
.locator(
|
||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||
)
|
||
.first();
|
||
await expect(row).toBeVisible();
|
||
|
||
const baseSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(baseSegment).toBeVisible();
|
||
|
||
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||
expect(dayWidth).toBeGreaterThan(8);
|
||
|
||
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
|
||
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||
await expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026");
|
||
await expect(carveDateInputs.nth(3)).toHaveValue("08/04/2026");
|
||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||
await expect(leftSplit).toBeVisible();
|
||
await expect(rightSplit).toBeVisible();
|
||
await expect(mondaySegment).toBeVisible();
|
||
|
||
await expect(leftSplit.locator('[data-allocation-handle="start"]')).toBeVisible();
|
||
await expect(leftSplit.locator('[data-allocation-handle="end"]')).toBeVisible();
|
||
await expect(rightSplit.locator('[data-allocation-handle="start"]')).toBeVisible();
|
||
await expect(rightSplit.locator('[data-allocation-handle="end"]')).toBeVisible();
|
||
|
||
await dragLocatorBy(page, leftSplit.locator('[data-allocation-handle="start"]'), dayWidth);
|
||
await releaseMouse(page);
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-07", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth);
|
||
await releaseMouse(page);
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-07", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-09" },
|
||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(row).toBeVisible();
|
||
|
||
const mondaySegmentAfterReload = row.locator(
|
||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||
).first();
|
||
await expect(mondaySegmentAfterReload).toBeVisible();
|
||
|
||
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||
const mondayCancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||
|
||
await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 0.5);
|
||
await expect(page.getByText("Loading...")).not.toBeVisible();
|
||
await expect(mondayCancelButton).toBeVisible();
|
||
await expect(mondayCarveDateInputs.nth(2)).toHaveValue("13/04/2026");
|
||
await expect(mondayCarveDateInputs.nth(3)).toHaveValue("13/04/2026");
|
||
await mondayCancelButton.click();
|
||
await expect(mondayCancelButton).not.toBeVisible();
|
||
|
||
await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 4.5);
|
||
await expect(page.getByText("Loading...")).not.toBeVisible();
|
||
await expect(mondayCancelButton).toBeVisible();
|
||
await expect(mondayCarveDateInputs.nth(2)).toHaveValue("17/04/2026");
|
||
await expect(mondayCarveDateInputs.nth(3)).toHaveValue("17/04/2026");
|
||
await mondayCancelButton.click();
|
||
await expect(mondayCancelButton).not.toBeVisible();
|
||
} finally {
|
||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||
}
|
||
});
|
||
|
||
test("resource timeline can delete a two-day weekday fragment without disturbing adjacent pieces", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
|
||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||
const scenario = createTimelineSegmentScenario(suffix);
|
||
|
||
try {
|
||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
|
||
const row = page
|
||
.locator(
|
||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||
)
|
||
.first();
|
||
await expect(row).toBeVisible();
|
||
|
||
const baseSegment = row.locator(
|
||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||
);
|
||
await expect(baseSegment).toBeVisible();
|
||
|
||
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||
expect(dayWidth).toBeGreaterThan(8);
|
||
|
||
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
|
||
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||
await carveDateInputs.nth(2).fill("08/04/2026");
|
||
await carveDateInputs.nth(3).fill("08/04/2026");
|
||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||
await expect(leftSplit).toBeVisible();
|
||
await expect(rightSplit).toBeVisible();
|
||
await expect(mondaySegment).toBeVisible();
|
||
|
||
await openAllocationContextMenuAtOffset(page, leftSplit, dayWidth * 0.5);
|
||
await carveDateInputs.nth(2).fill("06/04/2026");
|
||
await carveDateInputs.nth(3).fill("07/04/2026");
|
||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||
|
||
await waitForScenarioAssignments(scenario.projectId, [
|
||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||
]);
|
||
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||
).toHaveCount(0);
|
||
await expect(rightSplit).toBeVisible();
|
||
await expect(mondaySegment).toBeVisible();
|
||
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(row).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||
).toBeVisible();
|
||
await expect(
|
||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||
).toBeVisible();
|
||
} finally {
|
||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||
}
|
||
});
|
||
|
||
test("project view demand bars expose hover details", async ({ page }) => {
|
||
await page.getByText("Project view").click();
|
||
await expect(page.getByText(/projects/)).toBeVisible();
|
||
await expect(page.locator("[data-timeline-entry-type='demand']").first()).toBeVisible();
|
||
|
||
const demandId = await findVisibleTimelineEntryId(
|
||
page,
|
||
"[data-timeline-entry-type='demand'][data-allocation-id]",
|
||
72,
|
||
);
|
||
expect(demandId).toBeTruthy();
|
||
|
||
const demandBar = page.locator(
|
||
`[data-timeline-entry-type='demand'][data-allocation-id='${demandId}']`,
|
||
);
|
||
await demandBar.hover();
|
||
|
||
const demandTooltip = page.getByTestId("timeline-demand-tooltip");
|
||
await expect(demandTooltip).toBeVisible();
|
||
await expect(demandTooltip).toContainText("Requested");
|
||
await expect(demandTooltip).toContainText("Open");
|
||
await expect(demandTooltip).toContainText("Click for details and actions");
|
||
});
|
||
});
|