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 */ }, ); } } }); }