Files
TX/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
T
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
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
2026-05-14 06:23:49 +00:00

1061 lines
37 KiB
JavaScript

import { test, expect } from "@playwright/test";
import pg from "pg";
// E2E coverage for Tasks #227 and #230 — "Restructure attendee cell
// controls around section headings" inside executive-meetings.
//
// What we verify:
// 1. The top "+ عنوان فرعي" chip is NEVER rendered, regardless of
// cell state (empty / person-only / subheading-only / mixed).
// The trailing "+ عنوان فرعي" button at the bottom of the cell is
// the sole "start a heading" affordance, plus the after-section
// chips between consecutive sections.
// 2. After-section "+ عنوان فرعي" chip: rendered after the last
// person of each section that is followed by ANOTHER section
// (i.e. a subheading comes next). Clicking it inserts a new
// subheading at the boundary, BEFORE the existing next-section
// subheading. Also rendered after a subheading whose next item is
// another subheading (empty-section boundary).
// 3. Drag-and-drop reordering inside the manage dialog: picking up a
// row by its grip handle and moving it down with the keyboard
// reorders the attendee array, and saving persists the new order.
// All scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// same way.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings insert/reorder UI tests",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Pick a far-future, randomised base so we never collide with other
// specs that also seed in the future.
const RANDOM_DATE_BASE_DAYS =
2200 + 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);
}
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,
kind,
}) {
await pool.query(
`INSERT INTO executive_meeting_attendees
(meeting_id, name, attendance_type, sort_order, kind)
VALUES ($1, $2, $3, $4, $5)`,
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
);
}
async function getAttendeesOrdered(meetingId) {
const { rows } = await pool.query(
`SELECT name, attendance_type, sort_order, kind
FROM executive_meeting_attendees
WHERE meeting_id = $1
ORDER BY sort_order ASC`,
[meetingId],
);
return rows;
}
async function loginViaUi(page) {
await page.goto("/login");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("admin123");
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
let originalAdminPreferredLanguage = null;
async function setAdminPreferredLanguage(lang) {
const { rows } = await pool.query(
`UPDATE users SET preferred_language = $1
WHERE username = 'admin'
RETURNING preferred_language`,
[lang],
);
return rows[0]?.preferred_language;
}
// Toggle the global edit-mode toggle so the after-section
// "+ عنوان فرعي" chips and inline editing affordances render. The
// toggle lives in the schedule toolbar with
// data-testid="em-edit-mode-toggle".
async function ensureEditMode(page) {
const toggle = page.getByTestId("em-edit-mode-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
const pressed = await toggle.getAttribute("aria-pressed");
if (pressed !== "true") {
await toggle.click();
// Wait for the aria-pressed state to actually flip — the localStorage
// write + state setter is async-ish in React, and clicking the chip
// before edit mode commits would silently miss the conditional render.
await expect(toggle).toHaveAttribute("aria-pressed", "true", {
timeout: 5_000,
});
}
}
test.beforeAll(async () => {
const { rows } = await pool.query(
`SELECT preferred_language FROM users WHERE username = 'admin'`,
);
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
});
test.afterAll(async () => {
if (originalAdminPreferredLanguage) {
await pool
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
originalAdminPreferredLanguage,
])
.catch(() => {
/* best-effort */
});
}
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();
});
const langOffsets = { en: 1, ar: 2 };
const langOffsets2 = { en: 3, ar: 4 };
const langOffsets3 = { en: 5, ar: 6 };
const langOffsets4 = { en: 7, ar: 8 };
const langOffsets5 = { en: 9, ar: 10 };
for (const lang of ["en", "ar"]) {
test(`Schedule [${lang}]: top "+ subheading" chip is NEVER rendered (empty / person-only / subheading-only / mixed)`, async ({
page,
}) => {
// the top chip is gone in every state. We seed two
// meetings that, between them, exercise all four cell states:
// meetingEmptyId: a brand-new meeting with zero attendees ⇒
// the cell is EMPTY.
// meetingMixedId: three sections covering person-only,
// subheading-only, and mixed (subheading +
// persons) by spreading attendees across the
// three attendance-type groups (internal,
// virtual, external) which each render their own
// AttendeeFlow.
// For each addType we assert the top chip testid is absent. The
// trailing "+ subheading" button stays as the cell-level fallback.
const dateEmpty = uniqueFutureDate(langOffsets[lang]);
const meetingEmptyId = await insertMeeting({
meetingDate: dateEmpty,
dailyNumber: 1,
titleEn: `No Top Empty ${lang} ${Date.now().toString(36)}`,
titleAr: `بدون علوي فارغ ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
const dateMixed = uniqueFutureDate(langOffsets[lang] + 50);
const meetingMixedId = await insertMeeting({
meetingDate: dateMixed,
dailyNumber: 2,
titleEn: `No Top Mixed ${lang} ${Date.now().toString(36)}`,
titleAr: `بدون علوي مختلط ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// internal: person-only (two persons, zero subheadings).
await insertAttendee({
meetingId: meetingMixedId,
name: "InternalPerson1",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId: meetingMixedId,
name: "InternalPerson2",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
// virtual: subheading-only (one subheading, zero persons).
await insertAttendee({
meetingId: meetingMixedId,
name: "VirtualSoloHeader",
attendanceType: "virtual",
sortOrder: 2,
kind: "subheading",
});
// external: mixed (subheading + person).
await insertAttendee({
meetingId: meetingMixedId,
name: "ExternalHeader",
attendanceType: "external",
sortOrder: 3,
kind: "subheading",
});
await insertAttendee({
meetingId: meetingMixedId,
name: "ExternalPerson",
attendanceType: "external",
sortOrder: 4,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
// EMPTY cell ⇒ default single addType group "internal" renders
// with no items.
await page.locator('input[type="date"]').first().fill(dateEmpty);
const emptyRow = page.getByTestId(`em-row-${meetingEmptyId}`);
await expect(emptyRow).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
await expect(
emptyRow.locator('[data-testid^="em-add-subheading-top-"]'),
).toHaveCount(0);
// The trailing "+ subheading" button is still present so the
// user can begin sectioning the cell.
await expect(
emptyRow.getByTestId("em-add-subheading-internal"),
).toBeVisible();
// MIXED-meeting cell ⇒ three AttendeeFlow groups (internal /
// virtual / external) each in a different state. The top chip
// must be absent in every group. We prove all three groups
// mounted by checking each one's per-group trailing "+" person
// button is present (testid em-add-attendee-{addType}). NOTE:
// since, the trailing "+ subheading" chip is suppressed
// per group in split-mode cells and replaced by ONE cell-level
// chip (testid em-add-subheading-cell-{meetingId}) — so the
// per-group em-add-subheading-{addType} testids must NOT exist
// here, and the cell-level chip MUST be visible.
await page.locator('input[type="date"]').first().fill(dateMixed);
const mixedRow = page.getByTestId(`em-row-${meetingMixedId}`);
await expect(mixedRow).toBeVisible({ timeout: 15_000 });
// Per-group "+" person buttons prove all three groups mounted.
await expect(
mixedRow.getByTestId("em-add-attendee-internal"),
).toBeVisible();
await expect(
mixedRow.getByTestId("em-add-attendee-virtual"),
).toBeVisible();
await expect(
mixedRow.getByTestId("em-add-attendee-external"),
).toBeVisible();
// Per-group trailing "+ subheading" chips are SUPPRESSED in
// split mode. The cell-level chip is the sole subheading
// affordance below all three groups.
await expect(
mixedRow.getByTestId("em-add-subheading-internal"),
).toHaveCount(0);
await expect(
mixedRow.getByTestId("em-add-subheading-virtual"),
).toHaveCount(0);
await expect(
mixedRow.getByTestId("em-add-subheading-external"),
).toHaveCount(0);
await expect(
mixedRow.getByTestId(`em-add-subheading-cell-${meetingMixedId}`),
).toBeVisible();
// Top chip absent in every group.
await expect(
mixedRow.locator('[data-testid^="em-add-subheading-top-"]'),
).toHaveCount(0);
// Sanity counts (defence-in-depth so the assertion above isn't
// trivially passing because the groups silently rendered empty).
await expect(
mixedRow.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(3);
await expect(
mixedRow.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(2);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: after-section "+ subheading" chip inserts a subheading between two existing sections`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang] + 100);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Mid Subheading ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي وسطي ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Seed: [Sec1Header, Alpha, Beta, Sec2Header, Gamma]. The
// after-section chip is rendered after the last person of each
// section that has another section right after it. Beta sits at
// attendees idx 2 with Sec2Header at idx 3 → chip testid is
// `em-add-subheading-after-2`. The trailing section (Gamma) has
// nothing after it → no after-section chip there.
await insertAttendee({
meetingId,
name: "Sec1Header",
attendanceType: "internal",
sortOrder: 0,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonAlpha",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "PersonBeta",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Sec2Header",
attendanceType: "internal",
sortOrder: 3,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonGamma",
attendanceType: "internal",
sortOrder: 4,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
const chip = row.getByTestId("em-add-subheading-after-2");
await expect(chip).toBeVisible({ timeout: 10_000 });
// The trailing section's last person (Gamma at idx 4) must NOT
// have an after-section chip — the trailing "+ subheading"
// button below already covers that slot.
await expect(row.getByTestId("em-add-subheading-after-4")).toHaveCount(0);
await chip.click();
const pending = row.getByTestId("em-add-subheading-pending-internal");
await expect(pending).toBeVisible({ timeout: 5_000 });
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.fill("MidSection");
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(
row.locator('[data-testid^="em-attendee-row-"]'),
).toHaveCount(3, { timeout: 10_000 });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(3);
const persisted = await getAttendeesOrdered(meetingId);
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
const orderedNames = persisted.map((r) => stripTags(r.name));
// The new subheading must land BEFORE Sec2Header (the existing
// next-section subheading), at slot 3.
expect(orderedNames).toEqual([
"Sec1Header",
"PersonAlpha",
"PersonBeta",
"MidSection",
"Sec2Header",
"PersonGamma",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"subheading",
"person",
"person",
"subheading",
"subheading",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: after-section "+ subheading" chip renders after an EMPTY section (consecutive subheadings)`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang] + 200);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 4,
titleEn: `Empty Section ${lang} ${Date.now().toString(36)}`,
titleAr: `قسم فارغ ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Seed: [SubA, SubB, PersonZ]. SubA leads an EMPTY section (next
// item is also a subheading). The user must be able to insert a
// new subheading at the boundary between SubA and SubB — the chip
// is rendered AFTER SubA (testid em-add-subheading-after-0,
// insertAtIndex=1).
await insertAttendee({
meetingId,
name: "SubA",
attendanceType: "internal",
sortOrder: 0,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "SubB",
attendanceType: "internal",
sortOrder: 1,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonZ",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
// The empty-section chip lives right after SubA (idx 0), since
// SubB at idx 1 is also a subheading.
const chip = row.getByTestId("em-add-subheading-after-0");
await expect(chip).toBeVisible({ timeout: 10_000 });
await chip.click();
const pending = row.getByTestId("em-add-subheading-pending-internal");
await expect(pending).toBeVisible({ timeout: 5_000 });
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.fill("InsertedMid");
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(3, { timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
const orderedNames = persisted.map((r) => stripTags(r.name));
// The new subheading must land at idx 1 (between SubA and SubB).
expect(orderedNames).toEqual([
"SubA",
"InsertedMid",
"SubB",
"PersonZ",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"subheading",
"subheading",
"subheading",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Manage [${lang}]: drag handle reorders attendees inside the dialog`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets2[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Drag Reorder ${lang} ${Date.now().toString(36)}`,
titleAr: `سحب وإعادة ترتيب ${lang} ${Date.now().toString(36)}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await insertAttendee({
meetingId,
name: "Drag-A",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Drag-B",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Drag-C",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
// Switch to the Manage section via the page-level nav. The
// manage dialog is only mounted from inside the ManageSection.
const manageNav = page.getByTestId("em-nav-manage");
await expect(manageNav).toBeVisible({ timeout: 10_000 });
await manageNav.click();
await page.locator('input[type="date"]').first().fill(date);
// Open the edit dialog for our meeting (the pencil button on the
// row uses the meeting id as its testid suffix).
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
await expect(editBtn).toBeVisible({ timeout: 10_000 });
await editBtn.click();
// The dialog renders three sortable rows — verify drag handles
// are present and aria-labelled.
const handle0 = page.getByTestId("em-attendee-drag-0");
await expect(handle0).toBeVisible({ timeout: 5_000 });
// Use dnd-kit's KeyboardSensor flow to move row 0 down two
// slots. This is more reliable across Playwright browser engines
// than synthesised pointer drags. dnd-kit picks up Space and
// moves with arrow keys, then drops with Space again.
await handle0.focus();
await page.keyboard.press("Space");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Space");
// Verify the visible name inputs reordered to [B, C, A] before we
// even save — this catches DnD wiring without relying on a save
// round-trip.
const nameInputs = page.locator(
'[data-testid^="em-attendee-row-"] input[placeholder]',
);
// Filter to the first input of each row (the name field).
const firstNames = await page
.locator('[data-testid^="em-attendee-row-"]')
.evaluateAll((rows) =>
rows.map((r) => {
const inp = r.querySelector("input");
return inp ? inp.value : "";
}),
);
expect(firstNames).toEqual(["Drag-B", "Drag-C", "Drag-A"]);
void nameInputs;
// Save and verify persistence.
const saveBtn = page.getByTestId("em-form-save");
await saveBtn.click();
// Wait for the dialog to close.
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
expect(persisted.map((r) => r.name)).toEqual([
"Drag-B",
"Drag-C",
"Drag-A",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Manage [${lang}]: drag-reorder works across mixed person + subheading rows`, async ({
page,
}) => {
// Architect-flagged coverage: a meeting that contains BOTH person
// rows and a subheading row, then dragged so the subheading lands
// in a new section position. We verify (a) the kind survives the
// round-trip, (b) the new order persists, (c) numbering on the
// schedule view recomputes correctly after the kinds shift.
const date = uniqueFutureDate(langOffsets3[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Mixed Drag ${lang} ${Date.now().toString(36)}`,
titleAr: `سحب مختلط ${lang} ${Date.now().toString(36)}`,
startTime: "13:00:00",
endTime: "14:00:00",
});
// Seed: [PersonM, SubX, PersonN, PersonO]. We will pick up
// SubX (index 1) and move it down past PersonN (one ArrowDown).
// Expected after drag: [PersonM, PersonN, SubX, PersonO].
await insertAttendee({
meetingId,
name: "PersonM",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "MixSubX-K3",
attendanceType: "internal",
sortOrder: 1,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "PersonN",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await insertAttendee({
meetingId,
name: "PersonO",
attendanceType: "internal",
sortOrder: 3,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
const manageNav = page.getByTestId("em-nav-manage");
await expect(manageNav).toBeVisible({ timeout: 10_000 });
await manageNav.click();
await page.locator('input[type="date"]').first().fill(date);
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
await expect(editBtn).toBeVisible({ timeout: 10_000 });
await editBtn.click();
// Pick up the subheading row (index 1) and move it down once.
const handle1 = page.getByTestId("em-attendee-drag-1");
await expect(handle1).toBeVisible({ timeout: 5_000 });
await handle1.focus();
await page.keyboard.press("Space");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Space");
// Pre-save check: the row at index 1 is now PersonN (was the
// subheading), and the row at index 2 is the subheading.
const kinds = await page
.locator('[data-testid^="em-attendee-row-"]')
.evaluateAll((rows) =>
rows.map((r) => r.getAttribute("data-attendee-kind")),
);
expect(kinds).toEqual(["person", "person", "subheading", "person"]);
const saveBtn = page.getByTestId("em-form-save");
await saveBtn.click();
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
expect(persisted.map((r) => r.name)).toEqual([
"PersonM",
"PersonN",
"MixSubX-K3",
"PersonO",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"person",
"person",
"subheading",
"person",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: split-mode cell shows ONE cell-level "+ subheading" chip and routes to the LAST visible group`, async ({
page,
}) => {
// in a split-mode cell (≥2 attendance groups visible),
// the trailing "+ عنوان فرعي" chip must NOT be rendered per group;
// a single cell-level chip below all groups is rendered instead.
// Clicking it adds the new subheading to the LAST visible group in
// render order (virtual → internal → external). Per-group "+"
// person buttons are unaffected.
//
// hasSplit requires `virtual.length > 0 && (internal || external)`,
// so we seed all three groups. Render order makes EXTERNAL the
// last visible group, and the new subheading must persist at the
// end of the external group's rows.
const date = uniqueFutureDate(langOffsets4[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 7,
titleEn: `Cell-Level Subheading ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي للخلية ${lang} ${Date.now().toString(36)}`,
startTime: "14:00:00",
endTime: "15:00:00",
});
// Virtual: 1 person (just to satisfy hasSplit — render order
// virtual → internal → external means virtual is first).
await insertAttendee({
meetingId,
name: "VirtualP1",
attendanceType: "virtual",
sortOrder: 0,
kind: "person",
});
// Internal: 2 persons (mirrors the "الحضور الداخلي" block from
// the user's screenshot).
await insertAttendee({
meetingId,
name: "InternalP1",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "InternalP2",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
// External: 2 persons (mirrors the "الخارجي" block — the LAST
// visible group, so the cell-level chip routes here).
await insertAttendee({
meetingId,
name: "ExternalP1",
attendanceType: "external",
sortOrder: 3,
kind: "person",
});
await insertAttendee({
meetingId,
name: "ExternalP2",
attendanceType: "external",
sortOrder: 4,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
// Exactly ONE cell-level chip is rendered.
const cellChip = row.getByTestId(
`em-add-subheading-cell-${meetingId}`,
);
await expect(cellChip).toBeVisible({ timeout: 10_000 });
await expect(
row.locator('[data-testid^="em-add-subheading-cell-"]'),
).toHaveCount(1);
// Per-group trailing "+ subheading" chips are SUPPRESSED in all
// three groups.
await expect(
row.getByTestId("em-add-subheading-virtual"),
).toHaveCount(0);
await expect(
row.getByTestId("em-add-subheading-internal"),
).toHaveCount(0);
await expect(
row.getByTestId("em-add-subheading-external"),
).toHaveCount(0);
// Per-group "+" person buttons remain unchanged — one per group.
await expect(row.getByTestId("em-add-attendee-virtual")).toBeVisible();
await expect(row.getByTestId("em-add-attendee-internal")).toBeVisible();
await expect(row.getByTestId("em-add-attendee-external")).toBeVisible();
// Click the cell-level chip → pending input opens. Because the
// last visible group is external, the pending ghost lives in
// the external group's flow (testid uses the addType suffix).
await cellChip.click();
const pending = row.getByTestId(
"em-add-subheading-pending-external",
);
await expect(pending).toBeVisible({ timeout: 5_000 });
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.fill("CellChipSection");
await page.locator("body").click({ position: { x: 5, y: 5 } });
// Persisted: the new subheading lands at the end of the external
// group (sort_order 5, after the two external persons).
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(1, { timeout: 10_000 });
const persisted = await getAttendeesOrdered(meetingId);
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
expect(persisted.map((r) => stripTags(r.name))).toEqual([
"VirtualP1",
"InternalP1",
"InternalP2",
"ExternalP1",
"ExternalP2",
"CellChipSection",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"person",
"person",
"person",
"person",
"person",
"subheading",
]);
expect(persisted.map((r) => r.attendance_type)).toEqual([
"virtual",
"internal",
"internal",
"external",
"external",
"external",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: split-mode WITHOUT external — cell-level "+ subheading" chip routes to INTERNAL (last visible)`, async ({
page,
}) => {
// fallback path. The "last visible group" rule resolves
// the cell-level chip's target at click-time as
// external > internal > virtual. The previous test exercises the
// external-wins path; this one locks the internal-wins fallback by
// seeding ONLY virtual + internal (no external). The cell-level
// chip must still be the sole subheading affordance, and clicking
// it must persist a new subheading at the end of the INTERNAL
// group (not virtual, even though virtual is the "last seeded"
// group in sort_order).
const date = uniqueFutureDate(langOffsets5[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 9,
titleEn: `Cell Chip No External ${lang} ${Date.now().toString(36)}`,
titleAr: `لا خارجي ${lang} ${Date.now().toString(36)}`,
startTime: "16:00:00",
endTime: "17:00:00",
});
// Virtual: 1 person (satisfies hasSplit).
await insertAttendee({
meetingId,
name: "VirtP1",
attendanceType: "virtual",
sortOrder: 0,
kind: "person",
});
// Internal: 2 persons (this is the LAST visible group in render
// order virtual → internal → external when external is empty).
await insertAttendee({
meetingId,
name: "IntP1",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "IntP2",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
// Exactly one cell-level chip; no per-group subheading chips.
const cellChip = row.getByTestId(
`em-add-subheading-cell-${meetingId}`,
);
await expect(cellChip).toBeVisible({ timeout: 10_000 });
await expect(
row.getByTestId("em-add-subheading-virtual"),
).toHaveCount(0);
await expect(
row.getByTestId("em-add-subheading-internal"),
).toHaveCount(0);
// External didn't even render a group, so its subheading testid
// is also absent (defence-in-depth).
await expect(
row.getByTestId("em-add-subheading-external"),
).toHaveCount(0);
// Click → pending input opens in the INTERNAL group's flow.
await cellChip.click();
const pending = row.getByTestId(
"em-add-subheading-pending-internal",
);
await expect(pending).toBeVisible({ timeout: 5_000 });
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.fill("InternalTailSection");
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(
row.locator('[data-testid^="em-attendee-subheading-"]'),
).toHaveCount(1, { timeout: 10_000 });
// Persisted: the new subheading lands at the end of the
// internal group (sort_order 3, after the two internal persons).
const persisted = await getAttendeesOrdered(meetingId);
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
expect(persisted.map((r) => stripTags(r.name))).toEqual([
"VirtP1",
"IntP1",
"IntP2",
"InternalTailSection",
]);
expect(persisted.map((r) => r.kind)).toEqual([
"person",
"person",
"person",
"subheading",
]);
expect(persisted.map((r) => r.attendance_type)).toEqual([
"virtual",
"internal",
"internal",
"internal",
]);
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
}