Files
TX/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
T
riyadhafraa 96993df361 Task #234: Show one cell-level "+ عنوان فرعي" chip instead of one per group
Original task: in split-mode meeting cells (cells where 2+ attendance
groups are visible), the trailing "+ عنوان فرعي" chip was being
rendered once per AttendeeGroup. The user reported seeing two chips
(one for the internal group, one for the external group) and asked
"ليش اثنين؟". Per the user's choice in the clarifying interview, we
keep ONE chip at the bottom of the cell, and clicking it adds a new
subheading to the LAST visible attendance group in render order
(virtual → internal → external).

Implementation:
- Added optional `suppressTrailingSubheadingChip` prop to
  `AttendeeFlowSharedProps` (defaults to false, so single-group cells
  are unchanged).
- `AttendeeFlow` skips rendering its trailing per-group "+ عنوان فرعي"
  <li> when the prop is true. The per-group "+" person <li> and all
  after-section chips are unaffected.
- In the `hasSplit` branch of `AttendeesCell`, every per-group
  `AttendeeGroup` is now passed `suppressTrailingSubheadingChip`, and
  one new cell-level chip is rendered after the missing-groups chip
  block with `data-testid="em-add-subheading-cell-${meeting.id}"`. The
  chip is gated by the same `canMutate && !hasAnyPending && startAdd`
  guard as the other add chips. Its onClick computes
  `lastVisibleAddType` at click-time as `external > internal > virtual`
  and calls `startAdd(lastVisibleAddType, "subheading")`.

Tests:
- Updated `Schedule [en|ar]: top "+ subheading" chip is NEVER
  rendered…` so its mixed-meeting cell now asserts the per-group
  `em-add-subheading-{addType}` testids are absent in split mode and
  the new cell-level testid is visible. Per-group "+" person testids
  still prove all three groups mounted.
- Added `Schedule [en|ar]: split-mode cell shows ONE cell-level "+
  subheading" chip and routes to the LAST visible group`. Seeds
  virtual + internal + external attendees, asserts ONE cell-level
  chip + zero per-group subheading chips, clicks it, types a
  subheading, blurs, and verifies the new subheading persists at the
  end of the external group with correct kind / attendance_type /
  sort_order.
- Added `Schedule [en|ar]: split-mode WITHOUT external — cell-level "+
  subheading" chip routes to INTERNAL (last visible)` to lock the
  fallback path of the lastVisibleAddType resolution rule
  (external > internal > virtual). Seeds virtual + internal only and
  verifies the new subheading persists at the end of the internal
  group.

Verification:
- tsc clean for executive-meetings.tsx (admin.tsx errors are
  pre-existing and unrelated).
- All 4 new+modified tests pass (en+ar both for the modified top-chip
  test and the new cell-level chip test).
- The 4 unrelated test failures observed in the full run are
  pre-existing flakes (locale state pollution + dnd-kit RTL drag) and
  not caused by this change.
- Architect review: PASS. No critical findings.

Follow-up:
- Task #235 (PROPOSED) covers the remaining test gap: locking that
  the cell-level chip stays hidden in row B while another row A has
  a pending ghost input open (the in-code guard uses
  `canMutate && !hasAnyPending && startAdd`).
2026-04-30 18:47:29 +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,
}) => {
// Task #230: 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 Task #234, 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,
}) => {
// Task #234: 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,
}) => {
// Task #234 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 */
},
);
}
}
});
}