36cb2ca86e
Modify attendee numbering logic to reset at each subheading, ensuring proper sequence and display across different views and PDF outputs.
388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import pg from "pg";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Load the same locale bundles the app reads from so any copy edits to
|
|
// the subheading labels stay covered without test churn.
|
|
function loadLocale(lang) {
|
|
const p = join(__dirname, "..", "src", "locales", `${lang}.json`);
|
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
}
|
|
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
|
|
|
|
// E2E coverage for Task #207 — custom subheadings inside the attendees
|
|
// cell. Subheadings are user-defined section labels that live alongside
|
|
// person rows in a meeting's attendee list, but are NOT counted as
|
|
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
|
|
//
|
|
// What we verify:
|
|
// 1. Mixed group: a meeting with 2 internal persons + 1 subheading
|
|
// between them + 1 more person renders all four rows. The
|
|
// subheading carries `data-testid="em-attendee-subheading-…"`,
|
|
// and the three persons get the indices 1-, 2-, 3-.
|
|
// 2. Zero subheading: a meeting with only persons keeps the legacy
|
|
// numbering exactly as before (no extra "subheading" testids,
|
|
// no off-by-one).
|
|
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
|
|
// same.
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error(
|
|
"DATABASE_URL must be set to run the executive-meetings subheading UI tests",
|
|
);
|
|
}
|
|
// Parse to silence the "unused" lint until we use the bundle below.
|
|
void localeBundles;
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdMeetingIds = [];
|
|
|
|
// Pick a far-future, randomised base so we never collide with real
|
|
// scheduled meetings while running side by side with other specs that
|
|
// also seed in the future.
|
|
const RANDOM_DATE_BASE_DAYS =
|
|
900 + 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 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;
|
|
}
|
|
|
|
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 };
|
|
for (const lang of ["en", "ar"]) {
|
|
test(`Schedule [${lang}]: subheading rows render and do not advance the person index`, async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(langOffsets[lang]);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Subheading Mixed ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `عنوان فرعي مختلط ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
// Internal group with a subheading wedged between the second and
|
|
// third person. The subheading starts a fresh numbered section, so
|
|
// persons render as 1-, 2- (before the subheading) and 1- (after),
|
|
// PLUS the subheading row itself.
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Person Alpha",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Person Beta",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
// Distinctive label so the assertion below cannot trip on a
|
|
// person name that incidentally contains the same word.
|
|
name: "GuestsLabel-X9",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Person Gamma",
|
|
attendanceType: "internal",
|
|
sortOrder: 3,
|
|
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 });
|
|
|
|
// Exactly one subheading row in this meeting.
|
|
const subheadings = row.locator(
|
|
'[data-testid^="em-attendee-subheading-"]',
|
|
);
|
|
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
|
|
await expect(subheadings.first()).toContainText("GuestsLabel-X9");
|
|
|
|
// Three person rows rendered (subheading is excluded by the
|
|
// person-only `em-attendee-row-…` testid).
|
|
const personRows = row.locator('[data-testid^="em-attendee-row-"]');
|
|
await expect(personRows).toHaveCount(3);
|
|
|
|
// Numbering restarts after the subheading: section 1 has Alpha
|
|
// and Beta numbered "1-" and "2-"; section 2 has Gamma alone,
|
|
// numbered "1-" (the cell has a subheading so we always show the
|
|
// index, even for a solo person inside a section).
|
|
const indices = row.locator('[data-testid^="em-attendee-index-"]');
|
|
await expect(indices).toHaveCount(3);
|
|
const indexTexts = await indices.allInnerTexts();
|
|
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
|
|
expect(trimmed).toEqual(["1-", "2-", "1-"]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: subheading-only group survives deletion of all its persons (hasSplit preservation)`, async ({
|
|
page,
|
|
}) => {
|
|
// Regression guard for the hasSplit edge case the architect flagged:
|
|
// if a group still has a subheading after the user deletes every
|
|
// person in it, the group must keep its own AttendeeGroup wrapper
|
|
// (and its label) — its subheading should NOT silently fold into
|
|
// a sibling group's row. We seed virtual = [subheading] +
|
|
// internal = [persons] directly via the DB to skip the editing
|
|
// path and assert only the rendering invariant.
|
|
const date = uniqueFutureDate(langOffsets[lang] + 10);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Sub Solo Group ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `قسم بعنوان فرعي ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
// Virtual group: ONE subheading row, zero persons. This is the
|
|
// post-delete state described in the architect comment.
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Virtual Block Label",
|
|
attendanceType: "virtual",
|
|
sortOrder: 0,
|
|
kind: "subheading",
|
|
});
|
|
// Internal group: two real persons.
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Internal Person 1",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Internal Person 2",
|
|
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 });
|
|
|
|
// The subheading row must still be in the DOM, with its label
|
|
// text intact.
|
|
const subheadings = row.locator(
|
|
'[data-testid^="em-attendee-subheading-"]',
|
|
);
|
|
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
|
|
await expect(subheadings.first()).toContainText("Virtual Block Label");
|
|
|
|
// The two internal persons must still be numbered 1- and 2- (the
|
|
// virtual subheading does NOT get a number, and crucially does
|
|
// NOT push the internal counter to start at 2).
|
|
const indices = row.locator('[data-testid^="em-attendee-index-"]');
|
|
await expect(indices).toHaveCount(2);
|
|
const indexTexts = (await indices.allInnerTexts()).map((s) =>
|
|
s.trim().replace(/\s+/g, ""),
|
|
);
|
|
expect(indexTexts).toEqual(["1-", "2-"]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: meeting with no subheadings still numbers persons 1..N with no stray subheading rows`, async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(langOffsets[lang] + 5);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `No Subheadings ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `بدون عناوين فرعية ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Solo Person A",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Solo Person B",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
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 });
|
|
|
|
// Zero subheadings — make sure the new code path didn't sneak
|
|
// one in for legacy "person"-only meetings.
|
|
const subheadings = row.locator(
|
|
'[data-testid^="em-attendee-subheading-"]',
|
|
);
|
|
await expect(subheadings).toHaveCount(0);
|
|
|
|
const indices = row.locator('[data-testid^="em-attendee-index-"]');
|
|
await expect(indices).toHaveCount(2);
|
|
const trimmed = (await indices.allInnerTexts()).map((s) =>
|
|
s.trim().replace(/\s+/g, ""),
|
|
);
|
|
expect(trimmed).toEqual(["1-", "2-"]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|