7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
2202 lines
81 KiB
JavaScript
2202 lines
81 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
||
import pg from "pg";
|
||
|
||
// E2E coverage for keyboard editing in the executive-meetings schedule.
|
||
// These tests verify the keyboard contract for the three inline-edit
|
||
// cells (time range, title, attendee name):
|
||
//
|
||
// - Tab navigation reaches the cell (it is keyboard-reachable via
|
||
// tabIndex=0).
|
||
// - Pressing Enter on the focused display opens edit mode.
|
||
// - Pressing Enter inside the editor saves (PATCH/PUT succeeds,
|
||
// editor collapses to display mode).
|
||
// - Pressing Escape cancels — original value is restored, NO PATCH/PUT
|
||
// is sent, editor collapses.
|
||
// - Focus does not get stranded on an unmounted node after save/cancel
|
||
// (it returns to body or the row, not into the void).
|
||
//
|
||
// Each scenario seeds its own meeting row on a far-future date so we
|
||
// never collide with real schedule data, and afterAll cleanup deletes
|
||
// the rows we created.
|
||
|
||
const DATABASE_URL = process.env.DATABASE_URL;
|
||
if (!DATABASE_URL) {
|
||
throw new Error(
|
||
"DATABASE_URL must be set to run the executive-meetings keyboard-editing UI tests",
|
||
);
|
||
}
|
||
|
||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||
|
||
const createdMeetingIds = [];
|
||
|
||
async function loginViaUi(page, username, password) {
|
||
await page.goto("/login");
|
||
await page.locator("#username").fill(username);
|
||
await page.locator("#password").fill(password);
|
||
await Promise.all([
|
||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||
timeout: 15_000,
|
||
}),
|
||
page.locator('form button[type="submit"]').click(),
|
||
]);
|
||
}
|
||
|
||
// Wipe the schedule's persisted UI prefs so each test starts from a
|
||
// known baseline. Must be called AFTER login because the edit-mode
|
||
// storage key is namespaced by userId.
|
||
async function resetSchedulePrefs(page) {
|
||
await page.evaluate(() => {
|
||
try {
|
||
const keys = Object.keys(window.localStorage);
|
||
for (const k of keys) {
|
||
if (
|
||
k.startsWith("em-schedule-edit-mode-v1") ||
|
||
k === "em-schedule-cols-v1" ||
|
||
k === "em-schedule-row-colors-v1" ||
|
||
k === "em-current-meeting-highlight-v1"
|
||
) {
|
||
window.localStorage.removeItem(k);
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
}
|
||
|
||
async function setLangEn(page) {
|
||
await page.addInitScript(() => {
|
||
try {
|
||
window.localStorage.setItem("tx-lang", "en");
|
||
} catch {
|
||
/* localStorage may not be ready before navigation */
|
||
}
|
||
});
|
||
}
|
||
|
||
async function insertMeeting({
|
||
meetingDate,
|
||
dailyNumber,
|
||
titleEn,
|
||
titleAr,
|
||
startTime,
|
||
endTime,
|
||
}) {
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO executive_meetings
|
||
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
|
||
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
|
||
RETURNING id`,
|
||
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
|
||
);
|
||
const id = rows[0].id;
|
||
createdMeetingIds.push(id);
|
||
return id;
|
||
}
|
||
|
||
async function insertAttendee({ meetingId, name, attendanceType, sortOrder }) {
|
||
await pool.query(
|
||
`INSERT INTO executive_meeting_attendees
|
||
(meeting_id, name, attendance_type, sort_order)
|
||
VALUES ($1, $2, $3, $4)`,
|
||
[meetingId, name, attendanceType, sortOrder],
|
||
);
|
||
}
|
||
|
||
const RANDOM_DATE_BASE_DAYS =
|
||
730 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
|
||
function uniqueFutureDate(offsetDays) {
|
||
const d = new Date();
|
||
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// Switch the schedule into edit mode so the inline editors are
|
||
// interactive. Idempotent — clicks the toggle only when off.
|
||
async function ensureEditMode(page) {
|
||
const toggle = page.getByTestId("em-edit-mode-toggle");
|
||
await expect(toggle).toBeVisible();
|
||
const pressed = await toggle.getAttribute("aria-pressed");
|
||
if (pressed !== "true") {
|
||
await toggle.click();
|
||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||
}
|
||
}
|
||
|
||
// Common setup: log in, navigate to /executive-meetings, reset prefs,
|
||
// reload, jump to the seeded date, wait for the row, flip on edit mode.
|
||
async function gotoMeetingRow(page, meetingId, date) {
|
||
await loginViaUi(page, "admin", "admin123");
|
||
await page.goto("/executive-meetings");
|
||
await resetSchedulePrefs(page);
|
||
await page.reload();
|
||
await page.locator('input[type="date"]').first().fill(date);
|
||
const row = page.getByTestId(`em-row-${meetingId}`);
|
||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||
await ensureEditMode(page);
|
||
return row;
|
||
}
|
||
|
||
// Returns the data-testid attribute of the currently-focused element,
|
||
// or null when focus is on body / has no testid. Useful for asserting
|
||
// keyboard navigation lands on the right cell without depending on a
|
||
// fragile Tab count.
|
||
async function activeTestId(page) {
|
||
return await page.evaluate(() => {
|
||
const el = document.activeElement;
|
||
if (!el || el === document.body) return null;
|
||
return el.getAttribute("data-testid");
|
||
});
|
||
}
|
||
|
||
// Tab forward up to `maxSteps` times until the focused element has the
|
||
// given test id. Throws via expect() if we never reach it. We use this
|
||
// instead of a hard-coded number of Tab presses so the test stays
|
||
// stable against small DOM changes (e.g. extra row-overlay buttons).
|
||
async function tabUntilFocused(page, targetTestId, maxSteps = 25) {
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
const cur = await activeTestId(page);
|
||
if (cur === targetTestId) return;
|
||
await page.keyboard.press("Tab");
|
||
}
|
||
const cur = await activeTestId(page);
|
||
expect(
|
||
cur,
|
||
`expected focus to reach ${targetTestId} within ${maxSteps} Tab presses`,
|
||
).toBe(targetTestId);
|
||
}
|
||
|
||
// Wait for the schedule's day-level GET refetch to land. After every
|
||
// inline save the page calls qc.invalidateQueries({ queryKey: [
|
||
// "/api/executive-meetings", date ] }), which fires a fresh GET. The
|
||
// cell re-renders only AFTER that GET resolves, so polling for the new
|
||
// text without first awaiting the refetch can race.
|
||
function waitForDayRefetch(page, date) {
|
||
return page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === "/api/executive-meetings" &&
|
||
u.searchParams.get("date") === date &&
|
||
resp.request().method() === "GET" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
}
|
||
|
||
test.afterAll(async () => {
|
||
if (createdMeetingIds.length > 0) {
|
||
await pool.query(
|
||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||
[createdMeetingIds],
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
|
||
[createdMeetingIds],
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||
[createdMeetingIds],
|
||
);
|
||
}
|
||
await pool.end();
|
||
});
|
||
|
||
test("Schedule keyboard: Tab into time cell, Enter opens edit, valid times save via Enter and PATCH succeeds", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(1);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Keyboard Time ${Date.now().toString(36)}`,
|
||
titleAr: `وقت لوحة المفاتيح ${Date.now().toString(36)}`,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await expect(timeCell).toBeVisible();
|
||
// Display variant is rendered as <div role="button" tabindex="0">.
|
||
// Confirm the cell is keyboard-reachable: focus it via the DOM (a
|
||
// raw Tab walk is also covered by a separate tab-order test below;
|
||
// the exact step count from the start of the page would depend on
|
||
// how many overlay buttons render in this row, which is not the
|
||
// target of this assertion).
|
||
await timeCell.evaluate((el) => el.focus());
|
||
expect(await activeTestId(page)).toBe(`em-time-${meetingId}`);
|
||
|
||
// Enter on the focused display opens edit mode.
|
||
await page.keyboard.press("Enter");
|
||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||
await expect(startInput).toBeVisible();
|
||
await expect(endInput).toBeVisible();
|
||
// The start input auto-focuses on enter-edit so the user can begin
|
||
// typing immediately.
|
||
await expect(startInput).toBeFocused();
|
||
|
||
// Type valid new times.
|
||
await startInput.fill("13:30");
|
||
await endInput.focus();
|
||
await endInput.fill("14:45");
|
||
|
||
// Watch for the PATCH that the Enter keypress should trigger AND
|
||
// for the day-level GET refetch the page kicks off afterwards. The
|
||
// displayed cell only updates once the refetch resolves.
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
await page.keyboard.press("Enter");
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
// Editor must collapse back to display mode and show the new values.
|
||
// The display cell renders compact 12h with NO AM/PM marker per
|
||
// formatTime() — "13:30" (24h wire) renders as "1:30" in the cell.
|
||
// The DB row asserted below is the source of truth for the wire
|
||
// value; the regex here just confirms the display refreshed.
|
||
await expect(startInput).toHaveCount(0);
|
||
await expect(endInput).toHaveCount(0);
|
||
await expect(timeCell).toHaveText(/1:30\s*[\u2013\-]\s*2:45/, {
|
||
timeout: 10_000,
|
||
});
|
||
|
||
// The DB must reflect the PATCH (server is the source of truth).
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("13:30:00");
|
||
expect(String(rows[0].end_time)).toBe("14:45:00");
|
||
|
||
// Focus must return to a sensible element — either the (re-rendered)
|
||
// display cell or the document body. What it MUST NOT do is land on
|
||
// an unmounted input or escape the page entirely.
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
|
||
});
|
||
|
||
test("Schedule keyboard: time cell Esc restores original times and sends no PATCH", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(2);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Keyboard Time Esc ${Date.now().toString(36)}`,
|
||
titleAr: `إلغاء الوقت ${Date.now().toString(36)}`,
|
||
startTime: "08:15:00",
|
||
endTime: "09:45:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
// formatTime() renders compact 12h with no AM/PM marker, dropping
|
||
// the leading zero — "08:15" (24h wire) shows as "8:15" in the cell.
|
||
await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/);
|
||
|
||
// Spy on any PATCH to this meeting — Escape must not trigger one.
|
||
let patchSeen = false;
|
||
page.on("response", (resp) => {
|
||
try {
|
||
const u = new URL(resp.url());
|
||
if (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH"
|
||
) {
|
||
patchSeen = true;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
await timeCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||
await expect(startInput).toBeFocused();
|
||
|
||
// Type something different from the saved value so that, if Escape
|
||
// failed to cancel, we'd see a real change in the cell.
|
||
await startInput.fill("17:00");
|
||
await endInput.focus();
|
||
await endInput.fill("18:30");
|
||
|
||
// Esc cancels — editor closes, original values restored. Display
|
||
// cell uses the same compact 12h, no AM/PM, no-leading-zero shape
|
||
// formatTime() produces.
|
||
await page.keyboard.press("Escape");
|
||
await expect(startInput).toHaveCount(0);
|
||
await expect(endInput).toHaveCount(0);
|
||
await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/);
|
||
|
||
// Give the page a beat to make sure no late PATCH fires from a
|
||
// delayed blur path before we assert.
|
||
await page.waitForTimeout(400);
|
||
expect(patchSeen).toBe(false);
|
||
|
||
// DB must still hold the originals.
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("08:15:00");
|
||
expect(String(rows[0].end_time)).toBe("09:45:00");
|
||
|
||
// Focus is sensible after cancel (cell or body, not on a removed input).
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
|
||
});
|
||
|
||
test("Schedule keyboard: title cell Enter opens edit and Enter saves the new value via PATCH", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(3);
|
||
const uniq = Date.now().toString(36);
|
||
const originalTitle = `KbdTitle Original ${uniq}`;
|
||
// We append (instead of select-all + replace) because typing-as-append
|
||
// exercises the same Enter-saves contract without depending on the
|
||
// ProseMirror selectAll keymap (which can be flaky to drive from
|
||
// outside the editor in headless Chromium).
|
||
const appended = ` SAVED-${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: originalTitle,
|
||
titleAr: originalTitle,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
||
await expect(titleCell).toContainText(originalTitle);
|
||
|
||
// Confirm the title cell is keyboard-reachable (tabIndex=0) by
|
||
// focusing it directly, then press Enter to enter edit mode.
|
||
await titleCell.evaluate((el) => el.focus());
|
||
expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);
|
||
await page.keyboard.press("Enter");
|
||
|
||
const toolbar = page.getByTestId("em-edit-toolbar");
|
||
await expect(toolbar).toBeVisible();
|
||
const editorContent = titleCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeVisible();
|
||
await expect(editorContent).toBeFocused();
|
||
|
||
// The editor opens with the cursor at the end of the existing text
|
||
// (commands.focus("end")), so plain typing appends.
|
||
await page.keyboard.type(appended);
|
||
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
// Single-line title (multiline=false): Enter (no Shift) saves via
|
||
// the EditableCell keydown handler.
|
||
await page.keyboard.press("Enter");
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
const expectedTitle = originalTitle + appended;
|
||
await expect(toolbar).toBeHidden();
|
||
await expect(titleCell).toContainText(expectedTitle, { timeout: 10_000 });
|
||
|
||
// DB confirms the save round-tripped. The EditableCell saves to
|
||
// title_ar when the page is RTL (admin's preferredLanguage drives
|
||
// this) and title_en when LTR — so check whichever column actually
|
||
// received the saved HTML, exactly like the schedule-features
|
||
// formatting test does.
|
||
const { rows } = await pool.query(
|
||
`SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
const savedTitle = String(rows[0].title_ar).includes(appended)
|
||
? String(rows[0].title_ar)
|
||
: String(rows[0].title_en);
|
||
expect(savedTitle).toContain(originalTitle);
|
||
expect(savedTitle).toContain(appended);
|
||
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
test("Schedule keyboard: title cell Esc closes editor, restores the original, and sends no PATCH", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(4);
|
||
const uniq = Date.now().toString(36);
|
||
const originalTitle = `KbdTitleEsc Original ${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: originalTitle,
|
||
titleAr: originalTitle,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
||
await expect(titleCell).toContainText(originalTitle);
|
||
|
||
let patchSeen = false;
|
||
page.on("response", (resp) => {
|
||
try {
|
||
const u = new URL(resp.url());
|
||
if (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH"
|
||
) {
|
||
patchSeen = true;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
await titleCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
|
||
const toolbar = page.getByTestId("em-edit-toolbar");
|
||
await expect(toolbar).toBeVisible();
|
||
const editorContent = titleCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeFocused();
|
||
|
||
// Type a draft change that we expect Esc to throw away.
|
||
await page.keyboard.type(" DRAFT-NEVER-SAVED");
|
||
await page.keyboard.press("Escape");
|
||
await expect(toolbar).toBeHidden();
|
||
await expect(titleCell).toContainText(originalTitle);
|
||
await expect(titleCell).not.toContainText("DRAFT-NEVER-SAVED");
|
||
await page.waitForTimeout(400);
|
||
expect(patchSeen).toBe(false);
|
||
|
||
// DB still holds the original.
|
||
const { rows } = await pool.query(
|
||
`SELECT title_en FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].title_en)).toBe(originalTitle);
|
||
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
test("Schedule keyboard: attendee cell Enter opens edit and Enter saves the new name via PUT", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(5);
|
||
const uniq = Date.now().toString(36);
|
||
const originalName = `KbdAtt Original ${uniq}`;
|
||
// Append a suffix rather than replace, for the same reason as the
|
||
// title test above: it exercises the same Enter-saves contract
|
||
// without depending on an external select-all keymap.
|
||
const appended = ` SAVED-${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Att Host ${uniq}`,
|
||
titleAr: `مضيف ${uniq}`,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
await insertAttendee({
|
||
meetingId,
|
||
name: originalName,
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
});
|
||
|
||
const row = await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// The testid uses the original index in meeting.attendees (0).
|
||
// Scope under the seeded row so the selector stays unambiguous even
|
||
// if a future test ever shares the same date.
|
||
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
|
||
await expect(attendeeCell).toBeVisible();
|
||
await expect(attendeeCell).toContainText(originalName);
|
||
|
||
await attendeeCell.evaluate((el) => el.focus());
|
||
expect(await activeTestId(page)).toBe(`em-edit-attendee-0`);
|
||
await page.keyboard.press("Enter");
|
||
|
||
const toolbar = page.getByTestId("em-edit-toolbar");
|
||
await expect(toolbar).toBeVisible();
|
||
const editorContent = attendeeCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeVisible();
|
||
await expect(editorContent).toBeFocused();
|
||
|
||
// Cursor opens at end (focus("end")), so typing appends.
|
||
await page.keyboard.type(appended);
|
||
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
|
||
resp.request().method() === "PUT" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
await page.keyboard.press("Enter");
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
const expectedName = originalName + appended;
|
||
await expect(toolbar).toBeHidden();
|
||
await expect(attendeeCell).toContainText(expectedName, { timeout: 10_000 });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT name FROM executive_meeting_attendees
|
||
WHERE meeting_id = $1 ORDER BY sort_order`,
|
||
[meetingId],
|
||
);
|
||
expect(rows.length).toBe(1);
|
||
expect(String(rows[0].name)).toContain(originalName);
|
||
expect(String(rows[0].name)).toContain(appended);
|
||
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
|
||
});
|
||
|
||
test("Schedule keyboard: attendee cell Esc closes editor, restores the original, and sends no PUT", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(6);
|
||
const uniq = Date.now().toString(36);
|
||
const originalName = `KbdAttEsc Original ${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Att Host Esc ${uniq}`,
|
||
titleAr: `مضيف إلغاء ${uniq}`,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
await insertAttendee({
|
||
meetingId,
|
||
name: originalName,
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
});
|
||
|
||
const row = await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
|
||
await expect(attendeeCell).toContainText(originalName);
|
||
|
||
let putSeen = false;
|
||
page.on("response", (resp) => {
|
||
try {
|
||
const u = new URL(resp.url());
|
||
if (
|
||
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
|
||
resp.request().method() === "PUT"
|
||
) {
|
||
putSeen = true;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
await attendeeCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
|
||
const toolbar = page.getByTestId("em-edit-toolbar");
|
||
await expect(toolbar).toBeVisible();
|
||
const editorContent = attendeeCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeFocused();
|
||
|
||
await page.keyboard.type(" DRAFT-NEVER-SAVED");
|
||
await page.keyboard.press("Escape");
|
||
await expect(toolbar).toBeHidden();
|
||
await expect(attendeeCell).toContainText(originalName);
|
||
await expect(attendeeCell).not.toContainText("DRAFT-NEVER-SAVED");
|
||
await page.waitForTimeout(400);
|
||
expect(putSeen).toBe(false);
|
||
|
||
// DB unchanged.
|
||
const { rows } = await pool.query(
|
||
`SELECT name FROM executive_meeting_attendees
|
||
WHERE meeting_id = $1 ORDER BY sort_order`,
|
||
[meetingId],
|
||
);
|
||
expect(rows.length).toBe(1);
|
||
expect(String(rows[0].name)).toBe(originalName);
|
||
|
||
const focused = await activeTestId(page);
|
||
expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
|
||
});
|
||
|
||
test("Schedule keyboard: Tab from the title cell reaches the time cell in the same row", async ({
|
||
page,
|
||
}) => {
|
||
// Covers the keyboard-reachability requirement ("Tab into a time
|
||
// cell"). We seed a row with NO attendees so the tab path between
|
||
// the title and the time cell is short and stable.
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(7);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Tab Order ${Date.now().toString(36)}`,
|
||
titleAr: `ترتيب التبويب ${Date.now().toString(36)}`,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
||
await titleCell.evaluate((el) => el.focus());
|
||
expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);
|
||
|
||
// Walk forward with Tab until we land on the time cell. Bounded so a
|
||
// future regression that breaks tab order fails fast instead of
|
||
// tabbing forever.
|
||
await tabUntilFocused(page, `em-time-${meetingId}`, 30);
|
||
|
||
// Pressing Enter on the focused time display opens edit mode.
|
||
await page.keyboard.press("Enter");
|
||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused();
|
||
});
|
||
|
||
// -------------------------------------------------------------------
|
||
// typing on the keyboard + Enter must save.
|
||
//
|
||
// The historical bug: the time cell used <input type="time">, which
|
||
// only fires onChange when the value is a complete, canonical "HH:MM"
|
||
// string. Typing "9:30" without a leading zero, "0930" compact, "9:30
|
||
// PM", or Arabic-Indic digits left the controlled React state empty,
|
||
// so pressing Enter ran save() with the stale value, equality with
|
||
// the saved value short-circuited, no PATCH fired, and the cell
|
||
// snapped back to "—" (or the prior value) — looking to the user
|
||
// like Enter "didn't save".
|
||
//
|
||
// These cases exercise each typed shape through the full UI stack
|
||
// (keystrokes → React state → save() → PATCH → DB → refetched cell
|
||
// text) so a regression at any layer trips a test.
|
||
// -------------------------------------------------------------------
|
||
|
||
// Reusable helper: open a meeting's time cell, focus the start input,
|
||
// clear it, type a new value, optionally pick the AM/PM toggle, do
|
||
// the same for end, press Enter, and wait for the PATCH + day GET
|
||
// refetch.
|
||
//
|
||
// the time cell is now a 12-hour picker — typing a
|
||
// 1–12 hour without first picking AM or PM is intentionally an
|
||
// ambiguous parse error (the fix for the "1:15 silently saved as
|
||
// AM" bug). For typed shapes that the picker can resolve on its
|
||
// own (an explicit AM/PM marker like "9:30 PM", or a 24h-only hour
|
||
// like "13:30") `period` should be `null`; for ambiguous shapes
|
||
// pass "am" or "pm" so the helper clicks the matching toggle
|
||
// before pressing Enter.
|
||
async function typeTimesAndSave(
|
||
page,
|
||
meetingId,
|
||
date,
|
||
startText,
|
||
endText,
|
||
startPeriod = null,
|
||
endPeriod = null,
|
||
) {
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||
await expect(startInput).toBeVisible();
|
||
await expect(startInput).toBeFocused();
|
||
|
||
// Clear and type into start. Use page.keyboard.type for the AR-digit
|
||
// case so the OS IME doesn't intercept; for ASCII we use fill() which
|
||
// is faster and equivalent.
|
||
await startInput.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type(startText, { delay: 10 });
|
||
if (startPeriod) {
|
||
await page
|
||
.getByTestId(`em-time-start-period-${startPeriod}-${meetingId}`)
|
||
.click();
|
||
}
|
||
|
||
await endInput.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type(endText, { delay: 10 });
|
||
if (endPeriod) {
|
||
await page
|
||
.getByTestId(`em-time-end-period-${endPeriod}-${meetingId}`)
|
||
.click();
|
||
}
|
||
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
// Re-focus the end input so Enter triggers save() rather than
|
||
// re-activating the AM/PM toggle that was last clicked.
|
||
await endInput.focus();
|
||
await page.keyboard.press("Enter");
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
}
|
||
|
||
// Each row in this matrix is one accepted typing shape from the task's
|
||
// "Done looks like" list. Both fields use the same shape so the
|
||
// assertions stay simple, and the tested shapes intentionally vary in
|
||
// hour/minute so a test that accidentally passes by reading stale
|
||
// state (rather than the new value) would fail.
|
||
//
|
||
// `startPeriod` / `endPeriod` mirror's "the picker has no
|
||
// implicit default" rule: ambiguous typed shapes (1–12 hour, no
|
||
// marker) require the helper to click the AM/PM toggle, while
|
||
// shapes with an explicit marker or a 24h-only hour are resolved by
|
||
// the picker without a toggle pick.
|
||
const TYPING_SHAPES = [
|
||
{ offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", startPeriod: null, endPeriod: null, expectStart: "13:30:00", expectEnd: "14:45:00" },
|
||
{ offset: 9, label: "no leading zero + AM", startText: "9:30", endText: "10:45", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
||
{ offset: 10, label: "compact HHMM + AM", startText: "0930", endText: "1045", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" },
|
||
{ offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", startPeriod: null, endPeriod: null, expectStart: "09:30:00", expectEnd: "21:30:00" },
|
||
// The "0115 + PM = 13:15" shape from the task spec — the toggle
|
||
// promotes the typed compact hour to PM.
|
||
{ offset: 12, label: "compact + PM toggle", startText: "0115", endText: "0230", startPeriod: "pm", endPeriod: "pm", expectStart: "13:15:00", expectEnd: "14:30:00" },
|
||
];
|
||
|
||
for (const shape of TYPING_SHAPES) {
|
||
test(`Schedule keyboard: typing "${shape.label}" + Enter saves and PATCH succeeds`, async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
// Each shape gets its own future date offset so the loop never
|
||
// collides on the (date, daily_number) unique constraint when
|
||
// these tests run in the same worker.
|
||
const date = uniqueFutureDate(shape.offset);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `KbdType ${shape.label} ${Date.now().toString(36)}`,
|
||
titleAr: `إدخال ${shape.label} ${Date.now().toString(36)}`,
|
||
// Seed with an empty time cell so we exercise the
|
||
// "type from scratch" path the user reported broken.
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
await typeTimesAndSave(
|
||
page,
|
||
meetingId,
|
||
date,
|
||
shape.startText,
|
||
shape.endText,
|
||
shape.startPeriod,
|
||
shape.endPeriod,
|
||
);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveCount(0);
|
||
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveCount(0);
|
||
// Don't pin the displayed text format — formatTime() picks 12h/24h
|
||
// per locale and the dash glyph varies. The DB row is the source
|
||
// of truth and is asserted below.
|
||
await expect(timeCell).not.toHaveText("—", { timeout: 10_000 });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe(shape.expectStart);
|
||
expect(String(rows[0].end_time)).toBe(shape.expectEnd);
|
||
});
|
||
}
|
||
|
||
test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical HH:MM", async ({
|
||
page,
|
||
}) => {
|
||
// Default lang is Arabic — don't set EN. This mirrors how an Arabic
|
||
// user types times from their keyboard layout.
|
||
const date = uniqueFutureDate(13);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `KbdAr ${Date.now().toString(36)}`,
|
||
titleAr: `أرقام عربية ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 1–12 hours so the
|
||
// picker requires an explicit AM/PM choice — pass the
|
||
// Arabic morning marker via the toggle so the helper clicks ص.
|
||
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥", "am", "am");
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("09:30:00");
|
||
expect(String(rows[0].end_time)).toBe("10:45:00");
|
||
});
|
||
|
||
// -------------------------------------------------------------------
|
||
// dragging a visible meeting must produce a chronologically-
|
||
// sorted visible list, even when the same day contains hidden cancelled
|
||
// meetings. Before the fix, cancelled meetings (filtered out of
|
||
// `orderedMeetings` for display) were still included in the reorder
|
||
// POST under their raw-list positions, so the server's slot-swap stole
|
||
// chronologically-sorted slots for them and the visible rows ended up
|
||
// out of order on screen.
|
||
// -------------------------------------------------------------------
|
||
|
||
test("Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
|
||
const date = uniqueFutureDate(20);
|
||
// Three visible meetings (A, C, D) bracketing a cancelled meeting B
|
||
// sandwiched in the middle of the day's daily_number sequence. The
|
||
// cancelled meeting MUST keep its time slot and daily number; the
|
||
// visible meetings MUST end up in chronological start-time order
|
||
// top-to-bottom after the drag.
|
||
const a = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `DragA ${Date.now().toString(36)}`,
|
||
titleAr: `سحبأ ${Date.now().toString(36)}`,
|
||
startTime: "09:00:00",
|
||
endTime: "09:30:00",
|
||
});
|
||
const b = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 2,
|
||
titleEn: `DragB-cancelled ${Date.now().toString(36)}`,
|
||
titleAr: `سحبب-ملغى ${Date.now().toString(36)}`,
|
||
startTime: "10:00:00",
|
||
endTime: "10:30:00",
|
||
});
|
||
const c = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 3,
|
||
titleEn: `DragC ${Date.now().toString(36)}`,
|
||
titleAr: `سحبج ${Date.now().toString(36)}`,
|
||
startTime: "11:00:00",
|
||
endTime: "11:30:00",
|
||
});
|
||
const d = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 4,
|
||
titleEn: `DragD ${Date.now().toString(36)}`,
|
||
titleAr: `سحبد ${Date.now().toString(36)}`,
|
||
startTime: "12:00:00",
|
||
endTime: "12:30:00",
|
||
});
|
||
// Mark B cancelled directly via SQL so the visible list seen by
|
||
// dnd-kit is [A, C, D]; the underlying day still holds 4 rows.
|
||
await pool.query(
|
||
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
|
||
[b],
|
||
);
|
||
|
||
// Capture B's pre-reorder slot so we can assert it is untouched
|
||
// after the drag.
|
||
const { rows: bBeforeRows } = await pool.query(
|
||
`SELECT daily_number, start_time, end_time
|
||
FROM executive_meetings WHERE id = $1`,
|
||
[b],
|
||
);
|
||
const bBefore = bBeforeRows[0];
|
||
|
||
// Log in, navigate, switch to edit mode (drag handles only show
|
||
// when edit mode is on).
|
||
await loginViaUi(page, "admin", "admin123");
|
||
await page.goto("/executive-meetings");
|
||
await resetSchedulePrefs(page);
|
||
await page.reload();
|
||
await page.locator('input[type="date"]').first().fill(date);
|
||
const rowA = page.getByTestId(`em-row-${a}`);
|
||
const rowC = page.getByTestId(`em-row-${c}`);
|
||
const rowD = page.getByTestId(`em-row-${d}`);
|
||
await expect(rowA).toBeVisible({ timeout: 10_000 });
|
||
await expect(rowC).toBeVisible();
|
||
await expect(rowD).toBeVisible();
|
||
// Cancelled rows are hidden from the schedule (#273).
|
||
await expect(page.getByTestId(`em-row-${b}`)).toHaveCount(0);
|
||
await ensureEditMode(page);
|
||
|
||
// Drive the reorder through dnd-kit's keyboard sensor — focus the
|
||
// grip on the bottom visible row (D) and use Space + ArrowUp twice
|
||
// + Space to move it to the top. This exercises the REAL onDragEnd
|
||
// callback path (including the client's `orderedMeetings`-based
|
||
// index math) end-to-end. Headless pixel drag against
|
||
// SortableContext is unreliable, so keyboard drag is the high-
|
||
// fidelity equivalent — dnd-kit treats both the same downstream.
|
||
const gripD = page.getByTestId(`em-row-grip-${d}`);
|
||
await expect(gripD).toBeVisible();
|
||
await gripD.focus();
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === "/api/executive-meetings/reorder" &&
|
||
resp.request().method() === "POST" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
await page.keyboard.press("Space"); // pick up D
|
||
// Brief settle pause — dnd-kit re-measures the SortableContext after
|
||
// pick-up before the keyboard sensor accepts movement keys, so a
|
||
// back-to-back ArrowUp can otherwise be dropped on the floor.
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("ArrowUp"); // D over C
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("ArrowUp"); // D over A
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("Space"); // drop -> visible order [D, A, C]
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
// After the reorder the visible list must read D, A, C top-to-bottom
|
||
// and the times must be chronologically sorted.
|
||
const { rows: visibleAfter } = await pool.query(
|
||
`SELECT id, daily_number, start_time, end_time, status
|
||
FROM executive_meetings
|
||
WHERE meeting_date = $1 AND status <> 'cancelled'
|
||
ORDER BY daily_number ASC`,
|
||
[date],
|
||
);
|
||
expect(visibleAfter.map((r) => r.id)).toEqual([d, a, c]);
|
||
// Chronologically increasing.
|
||
for (let i = 1; i < visibleAfter.length; i++) {
|
||
expect(
|
||
String(visibleAfter[i - 1].start_time) <= String(visibleAfter[i].start_time),
|
||
).toBe(true);
|
||
}
|
||
// Concretely the slot-swap must produce 09:00, 11:00, 12:00 for D, A, C
|
||
// (B's 10:00 stays with B).
|
||
expect(String(visibleAfter[0].start_time)).toBe("09:00:00");
|
||
expect(String(visibleAfter[1].start_time)).toBe("11:00:00");
|
||
expect(String(visibleAfter[2].start_time)).toBe("12:00:00");
|
||
|
||
// Cancelled B: time slot AND daily_number must be unchanged.
|
||
const { rows: bAfterRows } = await pool.query(
|
||
`SELECT daily_number, start_time, end_time, status
|
||
FROM executive_meetings WHERE id = $1`,
|
||
[b],
|
||
);
|
||
const bAfter = bAfterRows[0];
|
||
expect(bAfter.status).toBe("cancelled");
|
||
expect(String(bAfter.start_time)).toBe(String(bBefore.start_time));
|
||
expect(String(bAfter.end_time)).toBe(String(bBefore.end_time));
|
||
expect(bAfter.daily_number).toBe(bBefore.daily_number);
|
||
});
|
||
|
||
test("Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot", async ({
|
||
page,
|
||
}) => {
|
||
// The slot-swap sort puts non-null startTimes first (chronological)
|
||
// and null-startTime rows last. After dragging the null row to the
|
||
// top of a 3-row visible day, it should inherit the first
|
||
// chronological slot (09:00) and the originally-null slot should
|
||
// shift to the bottom row.
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(25);
|
||
const a = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `NullA ${Date.now().toString(36)}`,
|
||
titleAr: `بلاأ ${Date.now().toString(36)}`,
|
||
startTime: "09:00:00",
|
||
endTime: "09:30:00",
|
||
});
|
||
const b = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 2,
|
||
titleEn: `NullB ${Date.now().toString(36)}`,
|
||
titleAr: `بلاب ${Date.now().toString(36)}`,
|
||
startTime: "10:00:00",
|
||
endTime: "10:30:00",
|
||
});
|
||
const n = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 3,
|
||
titleEn: `NullN ${Date.now().toString(36)}`,
|
||
titleAr: `بلان ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await loginViaUi(page, "admin", "admin123");
|
||
await page.goto("/executive-meetings");
|
||
await resetSchedulePrefs(page);
|
||
await page.reload();
|
||
await page.locator('input[type="date"]').first().fill(date);
|
||
const rowA = page.getByTestId(`em-row-${a}`);
|
||
const rowB = page.getByTestId(`em-row-${b}`);
|
||
const rowN = page.getByTestId(`em-row-${n}`);
|
||
await expect(rowA).toBeVisible({ timeout: 10_000 });
|
||
await expect(rowB).toBeVisible();
|
||
await expect(rowN).toBeVisible();
|
||
await ensureEditMode(page);
|
||
|
||
const gripN = page.getByTestId(`em-row-grip-${n}`);
|
||
await expect(gripN).toBeVisible();
|
||
await gripN.focus();
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === "/api/executive-meetings/reorder" &&
|
||
resp.request().method() === "POST" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
await page.keyboard.press("Space"); // pick up N (last row)
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("ArrowUp");
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("ArrowUp");
|
||
await page.waitForTimeout(150);
|
||
await page.keyboard.press("Space"); // drop -> visible order [N, A, B]
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
// Slots sorted chronologically (nulls last) were
|
||
// slot[0] = (09:00, dn=A's), slot[1] = (10:00, dn=B's), slot[2] = (null, dn=N's).
|
||
// Assignment N->slot[0], A->slot[1], B->slot[2] yields the row times below.
|
||
const { rows: visibleAfter } = await pool.query(
|
||
`SELECT id, daily_number, start_time, end_time
|
||
FROM executive_meetings
|
||
WHERE meeting_date = $1
|
||
ORDER BY daily_number ASC`,
|
||
[date],
|
||
);
|
||
const byId = Object.fromEntries(visibleAfter.map((r) => [r.id, r]));
|
||
expect(String(byId[n].start_time)).toBe("09:00:00");
|
||
expect(String(byId[a].start_time)).toBe("10:00:00");
|
||
expect(byId[b].start_time).toBe(null);
|
||
// dailyNumbers are a unique permutation 1..3 across the day.
|
||
const dns = visibleAfter.map((r) => r.daily_number).slice().sort();
|
||
expect(dns).toEqual([1, 2, 3]);
|
||
});
|
||
|
||
test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(14);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `KbdBad ${Date.now().toString(36)}`,
|
||
titleAr: `غير صالح ${Date.now().toString(36)}`,
|
||
startTime: "08:00:00",
|
||
endTime: "09:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Spy on PATCH — none should fire for an unparseable typed value.
|
||
let patchSeen = false;
|
||
page.on("response", (resp) => {
|
||
try {
|
||
const u = new URL(resp.url());
|
||
if (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH"
|
||
) {
|
||
patchSeen = true;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||
await expect(startInput).toBeFocused();
|
||
await startInput.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("25:99", { delay: 10 });
|
||
|
||
await page.keyboard.press("Enter");
|
||
|
||
// Editor must STAY open so the user can fix the typo. Toast surfaces
|
||
// the parse error.
|
||
await expect(startInput).toBeVisible();
|
||
await expect(startInput).toBeFocused();
|
||
// Match the toast title specifically. The toast also surfaces an
|
||
// ARIA live-region copy for screen readers, so without a scope the
|
||
// pattern resolves to two elements and trips strict mode.
|
||
await expect(
|
||
page
|
||
.locator('[data-component-name="ToastTitle"]')
|
||
.filter({ hasText: /Time format isn't valid|اكتب الوقت مثل/ }),
|
||
).toBeVisible({ timeout: 5_000 });
|
||
|
||
// No PATCH should have been sent for the bad value.
|
||
await page.waitForTimeout(300);
|
||
expect(patchSeen).toBe(false);
|
||
|
||
// DB stayed at the seeded values.
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("08:00:00");
|
||
expect(String(rows[0].end_time)).toBe("09:00:00");
|
||
});
|
||
|
||
test("Schedule keyboard: Tab walks start hour → start minute → start AM → start PM → end hour → end minute → end AM → end PM, preserves the typed start values, Enter on end saves both", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(15);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `KbdTab ${Date.now().toString(36)}`,
|
||
titleAr: `تابات ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const startInput = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const endInput = page.getByTestId(`em-time-end-${meetingId}`);
|
||
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
|
||
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
|
||
const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`);
|
||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||
await expect(startInput).toBeFocused();
|
||
|
||
// each side has THREE controls: hour input, minute
|
||
// input, AM/PM toggle. Tab walks through start hour → start minute
|
||
// → start AM → start PM → end hour → end minute → end AM → end PM.
|
||
// "7" + "15" is a 1–12 hour with no marker, so the picker requires
|
||
// an explicit AM/PM toggle pick before the value resolves. This
|
||
// test exercises the contract that the typed values survive every
|
||
// focus change along the way.
|
||
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
|
||
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
|
||
|
||
await page.keyboard.type("7", { delay: 10 });
|
||
await page.keyboard.press("Tab");
|
||
await expect(startMinute).toBeFocused();
|
||
await page.keyboard.type("15", { delay: 10 });
|
||
await page.keyboard.press("Tab");
|
||
await expect(startAm).toBeFocused();
|
||
// Activate AM via Space — the canonical button-keyboard contract.
|
||
await page.keyboard.press("Space");
|
||
await page.keyboard.press("Tab");
|
||
await expect(startPm).toBeFocused();
|
||
await page.keyboard.press("Tab");
|
||
await expect(endInput).toBeFocused();
|
||
// The typed start values must still be intact when focus reaches
|
||
// the end input — that's the regression this test guards.
|
||
await expect(startInput).toHaveValue("7");
|
||
await expect(startMinute).toHaveValue("15");
|
||
|
||
await page.keyboard.type("8", { delay: 10 });
|
||
await page.keyboard.press("Tab");
|
||
await expect(endMinute).toBeFocused();
|
||
await page.keyboard.type("45", { delay: 10 });
|
||
await page.keyboard.press("Tab");
|
||
await expect(endAm).toBeFocused();
|
||
await page.keyboard.press("Space");
|
||
// Move focus back to the end minute input so Enter triggers
|
||
// save() rather than re-activating the AM button under focus.
|
||
await page.keyboard.press("Tab");
|
||
await expect(endPm).toBeFocused();
|
||
await endMinute.focus();
|
||
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
await page.keyboard.press("Enter");
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("07:15:00");
|
||
expect(String(rows[0].end_time)).toBe("08:45:00");
|
||
});
|
||
|
||
// -------------------------------------------------------------------
|
||
// Mobile viewport (iPhone 375x667): the inline 12h picker has 6
|
||
// sub-controls per range (start hour + minute + AM + PM, end hour +
|
||
// minute + AM + PM, plus save/cancel buttons). On a narrow phone
|
||
// viewport the row used to push the end picker out of view, hiding
|
||
// the second time field from the user. The TimeRangeCell wrapper
|
||
// now uses `flex-wrap` so the end picker drops onto a second line
|
||
// when the cell can't fit both side-by-side. This test guards
|
||
// against a regression that breaks that wrap or shrinks the touch
|
||
// targets below the usable minimum on a phone-sized viewport.
|
||
// -------------------------------------------------------------------
|
||
|
||
test.describe("Schedule mobile viewport (iPhone 375)", () => {
|
||
test.use({ viewport: { width: 375, height: 667 } });
|
||
|
||
test("inline 12h picker: all 6 sub-controls are visible on iPhone, finger-tap sized, and a full hour+minute+AM/PM entry persists", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(20);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `MobilePicker ${Date.now().toString(36)}`,
|
||
titleAr: `محمول ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
// Tap the cell to enter edit mode (mimics a finger tap on
|
||
// mobile, exercising the click handler the read-only display
|
||
// exposes alongside the keyboard handler).
|
||
await timeCell.click();
|
||
|
||
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
|
||
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
|
||
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
|
||
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
|
||
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
|
||
const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`);
|
||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||
const saveBtn = page.getByTestId(`em-time-save-${meetingId}`);
|
||
|
||
// All 8 picker sub-controls + save button must be reachable on
|
||
// a 375px viewport. `toBeVisible` checks both the DOM presence
|
||
// AND that the element isn't clipped behind 0-size / display:
|
||
// none / off-screen — the regression we're guarding against.
|
||
for (const control of [
|
||
startHour,
|
||
startMinute,
|
||
startAm,
|
||
startPm,
|
||
endHour,
|
||
endMinute,
|
||
endAm,
|
||
endPm,
|
||
saveBtn,
|
||
]) {
|
||
await expect(control).toBeVisible();
|
||
}
|
||
|
||
// Touch-target check: each interactive control must be at
|
||
// least 28px tall so a finger tap lands cleanly. We use 28px
|
||
// (not Apple HIG's 44px) because the inline editor lives in
|
||
// a dense schedule table and h-8 (32px) is already a real
|
||
// jump from the prior 22px implementation; h<28px would be
|
||
// a regression worth catching.
|
||
for (const control of [startHour, startMinute, startAm, endPm, saveBtn]) {
|
||
const box = await control.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
expect(box.height).toBeGreaterThanOrEqual(28);
|
||
}
|
||
|
||
// Drive a complete hour + minute + AM/PM entry through tap
|
||
// interactions on both sides — this is the canonical mobile
|
||
// flow the previous single-row layout broke. "9" + "30" PM
|
||
// resolves to 21:30; "10" + "45" PM resolves to 22:45.
|
||
await startHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("9", { delay: 10 });
|
||
await startMinute.click();
|
||
await page.keyboard.type("30", { delay: 10 });
|
||
await startPm.click();
|
||
await expect(startPm).toHaveAttribute("aria-pressed", "true");
|
||
|
||
await endHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("10", { delay: 10 });
|
||
await endMinute.click();
|
||
await page.keyboard.type("45", { delay: 10 });
|
||
await endPm.click();
|
||
await expect(endPm).toHaveAttribute("aria-pressed", "true");
|
||
|
||
const savePromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
const refetchPromise = waitForDayRefetch(page, date);
|
||
// Tap the save button — proves the save button is reachable
|
||
// on a 375px viewport AND that the picker's commit() path
|
||
// fires from a touch interaction.
|
||
await saveBtn.click();
|
||
await Promise.all([savePromise, refetchPromise]);
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("21:30:00");
|
||
expect(String(rows[0].end_time)).toBe("22:45:00");
|
||
});
|
||
});
|
||
|
||
// -------------------------------------------------------------------
|
||
// stop the snap-back race + collapse the editor instantly.
|
||
// Three guards:
|
||
// 1. Save → immediate re-open of the editor must show the SAVED
|
||
// values pre-filled, not blank fields. (The bug the user
|
||
// reported as "the times disappeared, I have to re-fill them".)
|
||
// 2. Tapping save must close the editor BEFORE the PATCH round-trip
|
||
// resolves — otherwise the perceived save latency tracks
|
||
// network latency instead of React render time.
|
||
// 3. A failed PATCH must roll back the optimistic cache and
|
||
// re-open the editor with the user's draft intact, so a transient
|
||
// failure doesn't silently leave the row showing the optimistic
|
||
// times that never made it to the server.
|
||
// -------------------------------------------------------------------
|
||
|
||
test("Schedule time editor: save then immediately re-open shows the saved values pre-filled (no snap-back to blank)", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(316);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `SnapBack ${Date.now().toString(36)}`,
|
||
titleAr: `لا للارتداد ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.click();
|
||
|
||
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
|
||
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
|
||
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
|
||
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
|
||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||
|
||
// Use the keyboard.type + AM/PM-toggle pattern from the iPhone
|
||
// entry test above — proven to reliably reach commit() across
|
||
// viewports, and produces SPLIT picker state (hour/minute/period
|
||
// tracked separately) so the rollback / re-open assertions can
|
||
// check each sub-control's value independently.
|
||
await startHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("9", { delay: 10 });
|
||
await startMinute.click();
|
||
await page.keyboard.type("30", { delay: 10 });
|
||
await startPm.click();
|
||
await expect(startPm).toHaveAttribute("aria-pressed", "true");
|
||
await endHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("10", { delay: 10 });
|
||
await endMinute.click();
|
||
await page.keyboard.type("45", { delay: 10 });
|
||
await endPm.click();
|
||
await expect(endPm).toHaveAttribute("aria-pressed", "true");
|
||
|
||
// Wait for the PATCH so we know the server-side change landed;
|
||
// the test then immediately re-opens the cell to exercise the
|
||
// snap-back path. Click the save button (the user's path that
|
||
// triggered the original bug report).
|
||
const patchPromise = page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
await page.getByTestId(`em-time-save-${meetingId}`).click();
|
||
await patchPromise;
|
||
|
||
// The editor must have collapsed.
|
||
await expect(page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`)).toHaveCount(0);
|
||
|
||
// Re-open the editor BEFORE the GET refetch necessarily lands.
|
||
await timeCell.click();
|
||
|
||
// The picker must be seeded with the values we just saved — not
|
||
// blank, not the prior empty state, not a flash of either. The
|
||
// user typed 9:30 PM / 10:45 PM via the toggle.
|
||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("9");
|
||
await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("30");
|
||
await expect(page.getByTestId(`em-time-start-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
|
||
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("10");
|
||
await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("45");
|
||
await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
|
||
});
|
||
|
||
test("Schedule time editor: tapping save closes the editor before the PATCH round-trip resolves (perceived-latency)", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(317);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `FastClose ${Date.now().toString(36)}`,
|
||
titleAr: `إغلاق سريع ${Date.now().toString(36)}`,
|
||
startTime: null,
|
||
endTime: null,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Delay the PATCH response by 1.5 s so the test can observe the
|
||
// editor collapsing well before the network round-trip resolves.
|
||
let releasePatch;
|
||
const patchHeld = new Promise((resolve) => {
|
||
releasePatch = resolve;
|
||
});
|
||
await page.route(
|
||
`**/api/executive-meetings/${meetingId}`,
|
||
async (route) => {
|
||
if (route.request().method() !== "PATCH") {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
await patchHeld; // pause until the test releases us
|
||
await route.continue();
|
||
},
|
||
);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.click();
|
||
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
|
||
const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`);
|
||
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
|
||
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
|
||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||
// 6:00 PM – 7:15 PM via keyboard.type + PM toggle (matches the
|
||
// iPhone-entry pattern). 18:00 / 19:15 in 24h.
|
||
await startHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("6", { delay: 10 });
|
||
await startMinute.click();
|
||
await page.keyboard.type("00", { delay: 10 });
|
||
await startPm.click();
|
||
await expect(startPm).toHaveAttribute("aria-pressed", "true");
|
||
await endHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("7", { delay: 10 });
|
||
await endMinute.click();
|
||
await page.keyboard.type("15", { delay: 10 });
|
||
await endPm.click();
|
||
await expect(endPm).toHaveAttribute("aria-pressed", "true");
|
||
|
||
const tStart = Date.now();
|
||
await page.getByTestId(`em-time-save-${meetingId}`).click();
|
||
|
||
// The editor must collapse within React render time — well under
|
||
// the 1.5 s held PATCH. We use 800 ms as a generous ceiling that
|
||
// still proves the close is NOT gated on the network.
|
||
await expect(
|
||
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
|
||
).toHaveCount(0, { timeout: 800 });
|
||
const elapsed = Date.now() - tStart;
|
||
expect(elapsed).toBeLessThan(1000);
|
||
|
||
// The read-only display should show the optimistic times even
|
||
// though the PATCH hasn't returned yet. The schedule's compact
|
||
// 12-h display drops the leading zero on the hour and emits no
|
||
// AM/PM marker, so 18:00 → "6:00", 19:15 → "7:15".
|
||
const displayText = await timeCell.innerText();
|
||
expect(displayText).toMatch(/6:00/);
|
||
expect(displayText).toMatch(/7:15/);
|
||
|
||
// Now release the PATCH and let the refetch settle so cleanup runs
|
||
// against a stable state.
|
||
releasePatch();
|
||
await page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
await page.unroute(`**/api/executive-meetings/${meetingId}`);
|
||
});
|
||
|
||
test("Schedule time editor: a failed PATCH rolls back the optimistic times and re-opens the editor with the draft intact", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(318);
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Rollback ${Date.now().toString(36)}`,
|
||
titleAr: `استعادة ${Date.now().toString(36)}`,
|
||
startTime: "07:00:00",
|
||
endTime: "08:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Stub PATCH to 500 so the optimistic write must roll back.
|
||
await page.route(
|
||
`**/api/executive-meetings/${meetingId}`,
|
||
async (route) => {
|
||
if (route.request().method() !== "PATCH") {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
await route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ message: "Internal server error" }),
|
||
});
|
||
},
|
||
);
|
||
|
||
const timeCell = page.getByTestId(`em-time-${meetingId}`);
|
||
await timeCell.click();
|
||
// Overwrite the saved 07:00–08:00 with 11:00 – 12:30 (24h shortcut).
|
||
// After the 500, the row's read-only display must snap BACK to the
|
||
// original 7:00–8:00 (rollback succeeded), and the editor must
|
||
// re-open with the user's draft (11:00 – 12:30) so they can retry
|
||
// without retyping.
|
||
const startHour = page.getByTestId(`em-time-start-${meetingId}`);
|
||
const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`);
|
||
const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`);
|
||
const endHour = page.getByTestId(`em-time-end-${meetingId}`);
|
||
const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`);
|
||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||
await startHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("11", { delay: 10 });
|
||
await startMinute.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("00", { delay: 10 });
|
||
await startAm.click();
|
||
await expect(startAm).toHaveAttribute("aria-pressed", "true");
|
||
await endHour.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("12", { delay: 10 });
|
||
await endMinute.click();
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.press("Delete");
|
||
await page.keyboard.type("30", { delay: 10 });
|
||
await endPm.click();
|
||
await expect(endPm).toHaveAttribute("aria-pressed", "true");
|
||
|
||
await page.getByTestId(`em-time-save-${meetingId}`).click();
|
||
|
||
// Editor must re-open after the rollback fires.
|
||
await expect(
|
||
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
|
||
).toHaveCount(1, { timeout: 5_000 });
|
||
// Draft is preserved so the user can retry without retyping.
|
||
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("11");
|
||
await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("00");
|
||
await expect(page.getByTestId(`em-time-start-period-am-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
|
||
await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("12");
|
||
await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("30");
|
||
await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true");
|
||
|
||
// Cancel out and confirm the read-only display shows the ORIGINAL
|
||
// 7:00–8:00 — proves the optimistic cache write was rolled back.
|
||
await page.getByTestId(`em-time-cancel-${meetingId}`).click();
|
||
await expect(
|
||
page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`),
|
||
).toHaveCount(0);
|
||
const displayText = await timeCell.innerText();
|
||
expect(displayText).toMatch(/7:00/);
|
||
expect(displayText).toMatch(/8:00/);
|
||
|
||
// DB must be untouched.
|
||
const { rows } = await pool.query(
|
||
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].start_time)).toBe("07:00:00");
|
||
expect(String(rows[0].end_time)).toBe("08:00:00");
|
||
|
||
await page.unroute(`**/api/executive-meetings/${meetingId}`);
|
||
});
|
||
|
||
// ─── #317: optimistic-cache repaint for non-time inline editors ─────
|
||
//
|
||
// made the time cell repaint instantly via an optimistic
|
||
// React Query cache write. generalises that pattern to the
|
||
// title, attendee-name, and merge-text editors. The three tests below
|
||
// mirror the time-cell tests for the title cell:
|
||
//
|
||
// 1. With the PATCH delayed 1500 ms, the cell shows the new title
|
||
// within ~300 ms — proves the optimistic cache write repaints
|
||
// the read-only display before the network round-trip resolves.
|
||
// 2. With the PATCH stubbed to 500, the cell rolls back to the
|
||
// original title and an error toast surfaces — proves the
|
||
// rollback path actually restores cache state.
|
||
// 3. Attendee-name save shows the renamed attendee instantly with
|
||
// the PATCH delayed — proves the optimistic pattern covers the
|
||
// attendees PUT endpoint too, not just the meeting PATCH.
|
||
|
||
test("Schedule title editor: optimistic repaint shows the new title before the PATCH round-trip resolves", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(330);
|
||
const uniq = Date.now().toString(36);
|
||
const originalTitle = `Optimistic Original ${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: originalTitle,
|
||
titleAr: originalTitle,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Hold the PATCH for 1500 ms. The optimistic cache write must
|
||
// repaint the cell well before the response lands.
|
||
let patchHeld = false;
|
||
await page.route(
|
||
`**/api/executive-meetings/${meetingId}`,
|
||
async (route) => {
|
||
if (route.request().method() !== "PATCH") {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
patchHeld = true;
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
await route.continue();
|
||
},
|
||
);
|
||
|
||
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
||
await expect(titleCell).toContainText(originalTitle);
|
||
await titleCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
|
||
const editorContent = titleCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeFocused();
|
||
const appended = ` FAST-${uniq}`;
|
||
await page.keyboard.type(appended);
|
||
|
||
const t0 = Date.now();
|
||
await page.keyboard.press("Enter");
|
||
|
||
// The cell must show the new title within 300 ms of pressing Enter,
|
||
// long before the held PATCH (1500 ms) resolves. This proves the
|
||
// optimistic cache write is what's repainting the cell — not the
|
||
// GET refetch that fires after the PATCH.
|
||
await expect(titleCell).toContainText(originalTitle + appended, {
|
||
timeout: 300,
|
||
});
|
||
const elapsed = Date.now() - t0;
|
||
expect(elapsed).toBeLessThan(800);
|
||
expect(patchHeld).toBe(true);
|
||
|
||
// Editor must have collapsed (toolbar gone) too.
|
||
await expect(page.getByTestId("em-edit-toolbar")).toBeHidden();
|
||
|
||
// Wait for the held PATCH to actually land + the GET refetch to
|
||
// complete so the test cleanup doesn't race the in-flight request.
|
||
await page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
await waitForDayRefetch(page, date);
|
||
await page.unroute(`**/api/executive-meetings/${meetingId}`);
|
||
});
|
||
|
||
test("Schedule title editor: a failed PATCH rolls back the optimistic title and the cell snaps back to the original", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(331);
|
||
const uniq = Date.now().toString(36);
|
||
const originalTitle = `Rollback Title ${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: originalTitle,
|
||
titleAr: originalTitle,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Stub PATCH to 500 so the optimistic write must roll back.
|
||
await page.route(
|
||
`**/api/executive-meetings/${meetingId}`,
|
||
async (route) => {
|
||
if (route.request().method() !== "PATCH") {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
await route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ message: "Internal server error" }),
|
||
});
|
||
},
|
||
);
|
||
|
||
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
||
await titleCell.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const editorContent = titleCell.locator('[contenteditable="true"]');
|
||
await expect(editorContent).toBeFocused();
|
||
const appended = ` BROKEN-${uniq}`;
|
||
await page.keyboard.type(appended);
|
||
await page.keyboard.press("Enter");
|
||
|
||
// Wait for the 500 to actually arrive so the rollback runs.
|
||
await page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}` &&
|
||
resp.request().method() === "PATCH" &&
|
||
resp.status() === 500
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
|
||
// Failure UX (mirrors #316 time-cell): the editor must re-open with
|
||
// the user's typed draft intact so they can fix the error and
|
||
// retry without retyping. The contenteditable still shows
|
||
// "BROKEN-..." while the underlying read-only `value` (driving
|
||
// titleEn in the cache) is rolled back to the original.
|
||
await expect(
|
||
titleCell.locator('[contenteditable="true"]'),
|
||
).toContainText(originalTitle + appended, { timeout: 3_000 });
|
||
await expect(titleCell.locator('[contenteditable="true"]')).toBeFocused();
|
||
|
||
// Cancel the draft → cell must collapse back to the rolled-back
|
||
// ORIGINAL title with no trace of the appended fragment.
|
||
await page.keyboard.press("Escape");
|
||
await expect(
|
||
titleCell.locator('[contenteditable="true"]'),
|
||
).toHaveCount(0, { timeout: 3_000 });
|
||
await expect(titleCell).toContainText(originalTitle);
|
||
await expect(titleCell).not.toContainText(appended);
|
||
|
||
// DB must be untouched.
|
||
const { rows } = await pool.query(
|
||
`SELECT title_en, title_ar FROM executive_meetings WHERE id = $1`,
|
||
[meetingId],
|
||
);
|
||
expect(String(rows[0].title_en)).not.toContain(appended);
|
||
expect(String(rows[0].title_ar)).not.toContain(appended);
|
||
|
||
await page.unroute(`**/api/executive-meetings/${meetingId}`);
|
||
});
|
||
|
||
test("Schedule title editor: rapid click-switch from cell A to cell B commits BOTH edits to the server (no dropped saves)", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(333);
|
||
const uniq = Date.now().toString(36);
|
||
const aOriginal = `RapidA ${uniq}`;
|
||
const bOriginal = `RapidB ${uniq}`;
|
||
const meetingA = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: aOriginal,
|
||
titleAr: aOriginal,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
const meetingB = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 2,
|
||
titleEn: bOriginal,
|
||
titleAr: bOriginal,
|
||
startTime: "10:00:00",
|
||
endTime: "11:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingA, date);
|
||
// Verify both rows are visible before we start poking at them.
|
||
await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible();
|
||
|
||
// Record every PATCH that lands so we can assert both commits hit
|
||
// the server even though we never blurred cell A explicitly — the
|
||
// outside-pointerdown flush in EditableCell must commit it.
|
||
const patches = [];
|
||
page.on("request", (req) => {
|
||
if (req.method() !== "PATCH") return;
|
||
const u = new URL(req.url());
|
||
const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/);
|
||
if (!m) return;
|
||
patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null });
|
||
});
|
||
|
||
const cellA = page.getByTestId(`em-edit-title-${meetingA}`);
|
||
const cellB = page.getByTestId(`em-edit-title-${meetingB}`);
|
||
|
||
// Enter edit mode on A and type without committing.
|
||
await cellA.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const editorA = cellA.locator('[contenteditable="true"]');
|
||
await expect(editorA).toBeFocused();
|
||
const aAppended = ` -A-${uniq}`;
|
||
await page.keyboard.type(aAppended);
|
||
|
||
// Now move focus to cell B WITHOUT pressing Enter on A. We
|
||
// dispatch a pointerdown directly on cell B's element to trigger
|
||
// EditableCell's outside-pointerdown flush in cell A. Doing this
|
||
// via .evaluate() avoids Playwright's hit-test rejecting the
|
||
// click when cell A's portalled floating toolbar happens to
|
||
// overlap cell B.
|
||
await cellB.evaluate((el) => {
|
||
el.dispatchEvent(
|
||
new PointerEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||
);
|
||
});
|
||
// Then enter edit mode on cell B the same way every other test
|
||
// in this file does — focus + Enter.
|
||
await cellB.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
const editorB = cellB.locator('[contenteditable="true"]');
|
||
await expect(editorB).toBeFocused();
|
||
const bAppended = ` -B-${uniq}`;
|
||
await page.keyboard.type(bAppended);
|
||
await page.keyboard.press("Enter");
|
||
|
||
// Both PATCHes must have reached the server with the right bodies.
|
||
await expect
|
||
.poll(() => patches.filter((p) => p.id === meetingA).length, {
|
||
timeout: 5_000,
|
||
})
|
||
.toBeGreaterThanOrEqual(1);
|
||
await expect
|
||
.poll(() => patches.filter((p) => p.id === meetingB).length, {
|
||
timeout: 5_000,
|
||
})
|
||
.toBeGreaterThanOrEqual(1);
|
||
|
||
// Body may use titleEn or titleAr depending on the i18n language
|
||
// active at save-time (the page may default to Arabic) — assert
|
||
// against whichever field the patch carries.
|
||
const titleOf = (p) =>
|
||
String(p?.body?.titleEn ?? p?.body?.titleAr ?? "");
|
||
const aPatch = patches.find((p) => p.id === meetingA);
|
||
const bPatch = patches.find((p) => p.id === meetingB);
|
||
expect(titleOf(aPatch)).toContain(aAppended.trim());
|
||
expect(titleOf(bPatch)).toContain(bAppended.trim());
|
||
|
||
// And the DB must reflect both edits — proves nothing was silently
|
||
// dropped by the rapid cell switch.
|
||
await waitForDayRefetch(page, date);
|
||
const { rows } = await pool.query(
|
||
`SELECT id, title_en, title_ar FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||
[[meetingA, meetingB]],
|
||
);
|
||
const byId = Object.fromEntries(
|
||
rows.map((r) => [r.id, `${r.title_en ?? ""} ${r.title_ar ?? ""}`]),
|
||
);
|
||
expect(byId[meetingA]).toContain(aAppended.trim());
|
||
expect(byId[meetingB]).toContain(bAppended.trim());
|
||
});
|
||
|
||
test("Schedule title editor: rapid Tab traversal from cell A to cell B commits BOTH edits exactly once", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(334);
|
||
const uniq = Date.now().toString(36);
|
||
const aOriginal = `TabA ${uniq}`;
|
||
const bOriginal = `TabB ${uniq}`;
|
||
const meetingA = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: aOriginal,
|
||
titleAr: aOriginal,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
const meetingB = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 2,
|
||
titleEn: bOriginal,
|
||
titleAr: bOriginal,
|
||
startTime: "10:00:00",
|
||
endTime: "11:00:00",
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingA, date);
|
||
await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible();
|
||
|
||
// Count PATCHes per meeting so we can assert no duplicate commits
|
||
// (savingRef guard) AND that the keyboard-only path still saves.
|
||
const patches = [];
|
||
page.on("request", (req) => {
|
||
if (req.method() !== "PATCH") return;
|
||
const u = new URL(req.url());
|
||
const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/);
|
||
if (!m) return;
|
||
patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null });
|
||
});
|
||
|
||
const cellA = page.getByTestId(`em-edit-title-${meetingA}`);
|
||
const cellB = page.getByTestId(`em-edit-title-${meetingB}`);
|
||
|
||
await cellA.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
await expect(cellA.locator('[contenteditable="true"]')).toBeFocused();
|
||
const aAppended = ` -TabA-${uniq}`;
|
||
await page.keyboard.type(aAppended);
|
||
|
||
// Pure keyboard exit: Tab pulls focus out of cell A. Title cells
|
||
// have no onTabNext, so the editor lets the browser handle Tab —
|
||
// focusout fires and the 100 ms blur timer commits A's edit.
|
||
await page.keyboard.press("Tab");
|
||
|
||
// Now activate B via keyboard and edit it. Pressing Enter twice
|
||
// (once on cellB which is focusable, then Enter again to commit
|
||
// the inner editor's content) is too brittle, so just focus +
|
||
// Enter the way every other test does.
|
||
await cellB.evaluate((el) => el.focus());
|
||
await page.keyboard.press("Enter");
|
||
await expect(cellB.locator('[contenteditable="true"]')).toBeFocused();
|
||
const bAppended = ` -TabB-${uniq}`;
|
||
await page.keyboard.type(bAppended);
|
||
await page.keyboard.press("Enter");
|
||
|
||
// Both PATCHes must have arrived.
|
||
await expect
|
||
.poll(() => patches.filter((p) => p.id === meetingA).length, {
|
||
timeout: 5_000,
|
||
})
|
||
.toBeGreaterThanOrEqual(1);
|
||
await expect
|
||
.poll(() => patches.filter((p) => p.id === meetingB).length, {
|
||
timeout: 5_000,
|
||
})
|
||
.toBeGreaterThanOrEqual(1);
|
||
|
||
// …and EXACTLY once each — proves the savingRef re-entrancy guard
|
||
// suppresses duplicate commits between the pointerdown flush, the
|
||
// blur timer, and any onTabNext-style commit chains.
|
||
// Wait briefly so any late blur-timer PATCH would have landed.
|
||
await page.waitForTimeout(500);
|
||
expect(patches.filter((p) => p.id === meetingA).length).toBe(1);
|
||
expect(patches.filter((p) => p.id === meetingB).length).toBe(1);
|
||
|
||
const titleOf = (p) =>
|
||
String(p?.body?.titleEn ?? p?.body?.titleAr ?? "");
|
||
expect(titleOf(patches.find((p) => p.id === meetingA))).toContain(
|
||
aAppended.trim(),
|
||
);
|
||
expect(titleOf(patches.find((p) => p.id === meetingB))).toContain(
|
||
bAppended.trim(),
|
||
);
|
||
});
|
||
|
||
test("Schedule attendees editor: optimistic repaint shows the renamed attendee before the PUT round-trip resolves", async ({
|
||
page,
|
||
}) => {
|
||
await setLangEn(page);
|
||
const date = uniqueFutureDate(332);
|
||
const uniq = Date.now().toString(36);
|
||
const originalName = `OrigAtt ${uniq}`;
|
||
const meetingId = await insertMeeting({
|
||
meetingDate: date,
|
||
dailyNumber: 1,
|
||
titleEn: `Att Optimistic ${uniq}`,
|
||
titleAr: `حضور تفاؤلي ${uniq}`,
|
||
startTime: "09:00:00",
|
||
endTime: "10:00:00",
|
||
});
|
||
await insertAttendee({
|
||
meetingId,
|
||
name: originalName,
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
});
|
||
|
||
await gotoMeetingRow(page, meetingId, date);
|
||
|
||
// Hold the attendees PUT for 1500 ms.
|
||
let putHeld = false;
|
||
await page.route(
|
||
`**/api/executive-meetings/${meetingId}/attendees`,
|
||
async (route) => {
|
||
if (route.request().method() !== "PUT") {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
putHeld = true;
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
await route.continue();
|
||
},
|
||
);
|
||
|
||
// The attendee's EditableCell wrapper (`em-edit-attendee-${i}`) is
|
||
// index-keyed and the index is list-wide. Locate the cell that
|
||
// shows the original name, then capture its concrete testid so the
|
||
// post-rename assertions don't lose the locator (a hasText filter
|
||
// would stop matching once the optimistic update repaints the
|
||
// cell with the new name).
|
||
const meetingRow = page.getByTestId(`em-row-${meetingId}`);
|
||
const initialCell = meetingRow
|
||
.locator('[data-testid^="em-edit-attendee-"]')
|
||
.filter({ hasText: originalName })
|
||
.first();
|
||
await expect(initialCell).toBeVisible();
|
||
const cellTestId = await initialCell.getAttribute("data-testid");
|
||
if (!cellTestId) throw new Error("attendee cell testid missing");
|
||
const attendeeCell = page.getByTestId(cellTestId);
|
||
await attendeeCell.click();
|
||
const editor = attendeeCell.locator('[contenteditable="true"]');
|
||
await expect(editor).toBeFocused();
|
||
|
||
const renamed = `RENAMED-${uniq}`;
|
||
await page.keyboard.press("ControlOrMeta+a");
|
||
await page.keyboard.type(renamed);
|
||
|
||
const t0 = Date.now();
|
||
await page.keyboard.press("Enter");
|
||
|
||
// Cell must show the renamed attendee within 300 ms — well before
|
||
// the held 1500 ms PUT resolves.
|
||
await expect(attendeeCell).toContainText(renamed, { timeout: 300 });
|
||
const elapsed = Date.now() - t0;
|
||
expect(elapsed).toBeLessThan(800);
|
||
expect(putHeld).toBe(true);
|
||
// The original name must be gone from the optimistic display.
|
||
await expect(attendeeCell).not.toContainText(originalName);
|
||
|
||
// Wait for the held PUT to land + the GET refetch to complete so
|
||
// the test cleanup doesn't race the in-flight request.
|
||
await page.waitForResponse(
|
||
(resp) => {
|
||
const u = new URL(resp.url());
|
||
return (
|
||
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
|
||
resp.request().method() === "PUT" &&
|
||
resp.ok()
|
||
);
|
||
},
|
||
{ timeout: 15_000 },
|
||
);
|
||
await waitForDayRefetch(page, date);
|
||
await page.unroute(`**/api/executive-meetings/${meetingId}/attendees`);
|
||
});
|