76f0dfacb4
Modify attendee numbering logic to reset at each subheading, ensuring proper sequence and display across different views and PDF outputs. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 17c29c3f-e55d-4765-875c-0387b6936812 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/piUNOmy Replit-Helium-Checkpoint-Created: true
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 */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|