10 Commits
5 changed files with 281 additions and 68 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+28 -3
View File
@@ -13,14 +13,39 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
}); });
function formatPeriod(period) {
if (!period) return '';
return String(period)
.replace(/\b1ST\b/g, '1st')
.replace(/\b2ND\b/g, '2nd')
.replace(/\b3RD\b/g, '3rd')
.replace(/\b(\d+)TH\b/g, '$1th')
.toLowerCase()
.replace(/^./, (c) => c.toUpperCase());
}
async function generatePdf(data) { async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data); const bytes = await ISMIStools.buildStudyLoadPdf(data);
const blob = new Blob([bytes], { type: 'application/pdf' }); const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const name = (data.header && data.header.fullName) || 'Student'; const header = data.header || {};
const filename = 'Study Load - ' + name + '.pdf'; const year = parseInt(data.year, 10);
const period = formatPeriod(data.period || header.semester || '');
const compactProgram = ISMIStools.compactProgram(header.program || '');
const schoolYear = Number.isFinite(year) ? `${year}-${year + 1}` : '';
const parts = [
header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''),
compactProgram,
schoolYear ? 'S.Y. ' + schoolYear : '',
period,
'Study Load'
].filter(Boolean);
const filename = parts.join(' - ') + '.pdf';
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
const safeFilename = filename || fallback;
try { try {
const id = await browser.downloads.download({ url, filename, saveAs: true }); const id = await browser.downloads.download({ url, filename: safeFilename, saveAs: true });
const onChanged = (delta) => { const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged); browser.downloads.onChanged.removeListener(onChanged);
+14 -1
View File
@@ -102,6 +102,14 @@
const academicYear = yearEl ? yearEl.value : ''; const academicYear = yearEl ? yearEl.value : '';
const token = tokenEl ? tokenEl.value : ''; const token = tokenEl ? tokenEl.value : '';
// Semester/year come from the search dropdowns the user picked.
// AcademicPeriod's selected option label (e.g. "1ST Semester") + year.
const periodLabel = (periodEl && periodEl.selectedOptions && periodEl.selectedOptions[0])
? periodEl.selectedOptions[0].textContent.trim()
: '';
const selectedPeriod = periodLabel || academicPeriod;
const selectedYear = academicYear;
const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))]; const uniqueCodes = [...new Set(courses.map((c) => c.courseCode))];
const descMap = {}; const descMap = {};
for (const code of uniqueCodes) { for (const code of uniqueCodes) {
@@ -123,7 +131,12 @@
header = {}; header = {};
} }
return { header, courses }; // Semester goes into the header so the PDF shows the dropdown's term/year.
header.semester = (selectedPeriod && selectedYear)
? selectedPeriod + ' ' + selectedYear
: (header.semester || '');
return { header, courses, period: selectedPeriod, year: selectedYear };
} }
// ---- On-page Download PDF button ---- // ---- On-page Download PDF button ----
+200 -63
View File
@@ -29,7 +29,33 @@
return lines.length ? lines : ['']; return lines.length ? lines : [''];
} }
// Wraps multi-line text (segments split on \n) into wrapped lines. // Format the raw schedule string to match the official PDF style:
// comma-separate day codes, trim duplicated room suffixes, and split
// multiple meeting segments onto separate lines.
function formatSchedule(schedule) {
let s = String(schedule || '').replace(/\s+/g, ' ').trim();
if (!s) return s;
// Collapse duplicated TC anywhere in the string.
while (/TC\s+TC/.test(s)) s = s.replace(/TC\s+TC/g, 'TC');
// Join adjacent day codes with commas.
s = s.replace(/\b(M|Tu|T|W|Th|F|Sa|Su)\s+(M|Tu|T|W|Th|F|Sa|Su)\b/g, '$1,$2');
// Split concatenated meeting segments (room ending in TC followed by a
// day code) onto separate lines.
s = s.replace(/(TC)\s+(?=(?:Su|Sa|Th|Tu|M|W|F|T)\b)/g, '$1\n');
return s;
}
// Reformat a full name to "Lastname, Firstname Middle" when the source gives
// it as "Firstname Middle Lastname" (no comma). If a comma is already
// present the name is returned unchanged.
function formatFullName(name) {
const s = String(name || '').trim();
if (!s || /,/.test(s)) return s;
const parts = s.split(/\s+/);
if (parts.length < 2) return s;
const last = parts.pop();
return last + ', ' + parts.join(' ');
}
function wrapLines(text, font, size, maxWidth) { function wrapLines(text, font, size, maxWidth) {
const out = []; const out = [];
String(text == null ? '' : text).split(/\n/).forEach((seg) => { String(text == null ? '' : text).split(/\n/).forEach((seg) => {
@@ -38,12 +64,58 @@
return out.length ? out : ['']; return out.length ? out : [''];
} }
// Compact a degree name for display:
// "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT".
// Multi-word fields become initials; single-word fields stay title-cased.
function compactProgram(program) {
const s = String(program || '').trim().toUpperCase();
let m = /^BACHELOR OF SCIENCE IN (.+)$/.exec(s);
let prefix = 'BS';
if (!m) {
m = /^BACHELOR OF ARTS IN (.+)$/.exec(s);
if (m) prefix = 'AB';
}
if (!m) return String(program || '').trim();
const field = m[1];
const words = field.split(/\s+/).filter((w) => !/^(AND|OF|THE|IN)$/.test(w));
if (words.length <= 1) {
const single = words[0] || field;
return prefix + ' ' + single.charAt(0).toUpperCase() + single.slice(1).toLowerCase();
}
return prefix + ' ' + words.map((w) => w[0]).join('');
}
// Map a semester to the official date range label.
function deriveDateRange(semester) {
const s = String(semester || '').toUpperCase();
if (/1ST|FIRST/.test(s)) return 'AUGUST - JANUARY';
if (/2ND|SECOND/.test(s)) return 'JANUARY - MAY';
if (/SUMMER/.test(s)) return 'JUNE - JULY';
return '';
}
// Load the bundled header logo (JPEG) in the extension background page.
// Returns null in Node (tests) or if the asset is unavailable.
async function loadLogo() {
try {
const url = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL)
? browser.runtime.getURL('assets/logo.jpeg')
: null;
if (!url) return null;
const resp = await fetch(url);
if (!resp.ok) return null;
return new Uint8Array(await resp.arrayBuffer());
} catch (e) {
return null;
}
}
// Builds a Letter-size PDF replicating the official ISMIS Study Load layout: // Builds a Letter-size PDF replicating the official ISMIS Study Load layout:
// title block (STUDENT'S STUDY LOAD, semester, date range, date enrolled), // header logo, title, semester + date range, date enrolled, a student info
// a student info row (ID, name, program, Year Level), the course table // row (ID, name, program, Year Level), the course table (GROUP, COURSE,
// (GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE), and a footer // COURSE DESCRIPTION, TEACHER, UNITS, NOTE), and a footer (total under the
// (total units, printed-by). NOTE is the status; UNITS/NOTE are right-aligned; // UNITS column, printed-by). NOTE is the status; schedule is stacked under
// schedule is stacked under the course description. // the course description.
async function buildStudyLoadPdf(data) { async function buildStudyLoadPdf(data) {
const PDFLib = getPDFLib(); const PDFLib = getPDFLib();
const { PDFDocument, StandardFonts, rgb } = PDFLib; const { PDFDocument, StandardFonts, rgb } = PDFLib;
@@ -55,13 +127,19 @@
const font = doc.embedStandardFont(StandardFonts.Helvetica); const font = doc.embedStandardFont(StandardFonts.Helvetica);
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold); const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique); const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
const margin = 50; const margin = 36;
const contentWidth = 612 - margin * 2; const contentWidth = 539.3; // matches reference right edge 575.3
let y = 792 - 50; let y = 792 - 10; // near the very top, matching the reference logo position
const black = rgb(0, 0, 0); const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3); const gray = rgb(0.3, 0.3, 0.3);
const logoBytes = await loadLogo();
let logoImg = null;
if (logoBytes) {
try { logoImg = await doc.embedJpg(logoBytes); } catch (e) { logoImg = null; }
}
function center(text, size, f, dy) { function center(text, size, f, dy) {
const w = f.widthOfTextAtSize(text, size); const w = f.widthOfTextAtSize(text, size);
page.drawText(text, { x: (612 - w) / 2, y, size, font: f, color: black }); page.drawText(text, { x: (612 - w) / 2, y, size, font: f, color: black });
@@ -76,39 +154,62 @@
} }
// ---- Header ---- // ---- Header ----
center("STUDENT'S STUDY LOAD", 9.5, bold, 18); if (logoImg) {
if (header.semester) center(header.semester, 9, font, 15); const logoW = 292.5;
if (header.dateRange) center(header.dateRange, 9, font, 15); const logoH = logoW * (logoImg.height / logoImg.width);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13); page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH });
y -= logoH + 8;
// Student info row: ID (bold), name, program, "Year Level: N"
const studentParts = [];
if (header.studentId) studentParts.push({ text: header.studentId, f: bold });
if (header.fullName) studentParts.push({ text: header.fullName, f: font });
if (header.program) studentParts.push({ text: header.program, f: font });
if (header.yearLevel) studentParts.push({ text: 'Year Level: ' + header.yearLevel, f: font });
if (studentParts.length) {
let sx = margin;
for (const part of studentParts) {
const w = part.f.widthOfTextAtSize(part.text, 9);
page.drawText(part.text, { x: sx, y, size: 9, font: part.f, color: black });
sx += w + 18;
}
y -= 16;
} }
center("STUDENT'S STUDY LOAD", 9.5, bold, 14);
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5);
const dateRange = header.dateRange || deriveDateRange(header.semester);
if (dateRange) center(dateRange, 9, font, 13.5);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13.5);
// Student info row, laid out in fixed zones across the page (matching the
// reference): ID (bold) left, name at ~100, program at ~338, and
// "Year Level: N" right-aligned to the right content edge (575.3).
const yearLevelText = header.yearLevel ? 'Year Level: ' + header.yearLevel : '';
if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black });
if (header.fullName) page.drawText(formatFullName(header.fullName), { x: 100, y, size: 9, font, color: black });
if (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black });
if (yearLevelText) {
const w = font.widthOfTextAtSize(yearLevelText, 9);
page.drawText(yearLevelText, { x: margin + contentWidth - w, y, size: 9, font, color: black });
}
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5;
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ---- // ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
const cols = [ const cols = [
{ key: 'group', label: 'GROUP', x: margin, w: 26, align: 'left' }, { key: 'group', label: 'GROUP', x: 36.0, w: 28.8, align: 'left' },
{ key: 'courseCode', label: 'COURSE', x: margin + 26, w: 46, align: 'left' }, { key: 'courseCode', label: 'COURSE', x: 64.8, w: 47.6, align: 'left' },
{ key: 'description', label: 'COURSE DESCRIPTION', x: margin + 72, w: 189, align: 'left' }, { key: 'description', label: 'COURSE DESCRIPTION', x: 112.4, w: 201.4, align: 'left' },
{ key: 'teacher', label: 'TEACHER', x: margin + 261, w: 123, align: 'left' }, { key: 'teacher', label: 'TEACHER', x: 313.8, w: 127.6, align: 'left' },
{ key: 'units', label: 'UNITS', x: margin + 384, w: 31, align: 'right' }, { key: 'units', label: 'UNITS', x: 441.4, w: 29.5, align: 'right' },
{ key: 'status', label: 'NOTE', x: margin + 415, w: 97, align: 'right' } { key: 'status', label: 'NOTE', x: 470.9, w: 104.4, align: 'right' }
]; ];
const cellSize = 8; const cellSize = 8;
const headerSize = 8; const headerSize = 8;
const lineH = 10; const lineH = 12;
// Header label positions tuned for Helvetica: left/center aligned within
// each column so labels never overlap.
function headerLabelXFor() {
const widths = {};
for (const c of cols) {
const key = c.label.toUpperCase().replace(/\s+/g, '_');
widths[key] = bold.widthOfTextAtSize(c.label, headerSize);
}
return {
GROUP: cols[0].x,
COURSE: cols[1].x + 5,
COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2,
TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2,
UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2,
NOTE: cols[5].x + (cols[5].w - widths.NOTE) / 2
};
}
function rowLines(cells) { function rowLines(cells) {
return cols.map((c) => { return cols.map((c) => {
@@ -116,7 +217,7 @@
if (c.key === 'description') { if (c.key === 'description') {
lines = wrapLines(cells.description, font, cellSize, c.w - 6); lines = wrapLines(cells.description, font, cellSize, c.w - 6);
const sched = String(cells.schedule || ''); const sched = String(cells.schedule || '');
if (sched) lines.push(...wrapText(sched, font, cellSize, c.w - 6)); if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6));
} else { } else {
lines = wrapLines(cells[c.key], font, cellSize, c.w - 6); lines = wrapLines(cells[c.key], font, cellSize, c.w - 6);
} }
@@ -128,40 +229,72 @@
const f = isHeader ? bold : font; const f = isHeader ? bold : font;
const size = isHeader ? headerSize : cellSize; const size = isHeader ? headerSize : cellSize;
const linesPerCol = rowLines(cells); const linesPerCol = rowLines(cells);
const rowHeight = Math.max(...linesPerCol.map((l) => l.length)) * lineH + 8;
if (y - rowHeight < 50) {
page = doc.addPage([612, 792]);
y = 792 - 50;
drawHeaderRow();
}
if (isHeader) { if (isHeader) {
// top line above the header row // Reference header row: only a top and bottom horizontal rule; no
hline(margin, margin + contentWidth, y); // vertical dividers. Text baseline at y; top rule 1.8pt above the
// text bbox top; bottom rule 11.5pt below the text bbox top. For
// 8pt Helvetica the text bbox top sits ~8.6pt above the baseline,
// so the lines are offset by +9.6 (top) and -3.7 (bottom) relative
// to y. Data rows start 13pt below the header text bbox top.
const topY = y + 9.6;
const botY = y - 3.7;
if (botY < 50) {
page = doc.addPage([612, 792]);
y = 792 - 10;
return drawHeaderRow();
}
hline(margin, margin + contentWidth, topY);
const labelXs = headerLabelXFor();
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const labelKey = c.label.toUpperCase().replace(/\s+/g, '_');
const lx = labelXs[labelKey];
const lines = linesPerCol[i];
page.drawText(lines[0], { x: lx, y, size, font: f, color: black });
}
hline(margin, margin + contentWidth, botY);
y -= 13.0;
return true;
}
// Data rows: fixed 24.5pt baseline-to-baseline spacing. Description +
// schedule stack with 12pt line spacing. Expand the row height when
// the schedule or wrapped description needs more lines.
const maxLines = Math.max(...linesPerCol.map((l) => l.length));
const rowHeight = Math.max(24.5, maxLines * lineH + 0.5);
if (y - rowHeight < 50) {
page = doc.addPage([612, 792]);
y = 792 - 10;
drawHeaderRow();
} }
for (let i = 0; i < cols.length; i++) { for (let i = 0; i < cols.length; i++) {
const c = cols[i]; const c = cols[i];
const lines = linesPerCol[i]; const lines = linesPerCol[i];
let ty = y - 9; let ty = y;
for (const line of lines) { for (const line of lines) {
if (c.align === 'right') { if (c.key === 'units') {
const w = f.widthOfTextAtSize(line, size); const w = f.widthOfTextAtSize(line, size);
page.drawText(line, { x: c.x + c.w - 3 - w, y: ty, size, font: f, color: black }); page.drawText(line, { x: c.x + c.w - w, y: ty, size, font: f, color: black });
} else if (c.key === 'status') {
page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black });
} else { } else {
page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black }); let dx = 3;
if (c.key === 'group') dx = 12;
else if (c.key === 'courseCode') dx = 8;
else if (c.key === 'teacher') dx = 3.5;
else if (c.key === 'description') dx = 3.5;
page.drawText(line, { x: c.x + dx, y: ty, size, font: f, color: black });
} }
ty -= lineH; ty -= lineH;
} }
} }
y -= rowHeight; y -= rowHeight;
if (isHeader) {
// line below the header row
hline(margin, margin + contentWidth, y);
}
return true; return true;
} }
@@ -178,8 +311,8 @@
group: course.group, group: course.group,
courseCode: course.courseCode, courseCode: course.courseCode,
description: course.description, description: course.description,
schedule: course.schedule, schedule: formatSchedule(course.schedule),
teacher: course.teacher, teacher: course.teacher || course.faculty,
units: course.units, units: course.units,
status: course.status status: course.status
}; };
@@ -189,22 +322,26 @@
// ---- Footer ---- // ---- Footer ----
if (y < 90) { if (y < 90) {
page = doc.addPage([612, 792]); page = doc.addPage([612, 792]);
y = 792 - 50; y = 792 - 10;
} }
y -= 8; y -= 12;
if (data.totalUnits) { if (data.totalUnits) {
const w = bold.widthOfTextAtSize(data.totalUnits, 9); // Total sits under the UNITS column, right-aligned within it.
page.drawText(data.totalUnits, { x: margin + contentWidth - w, y, size: 9, font: bold, color: black }); const unitsCol = cols[4];
y -= 14; const w = bold.widthOfTextAtSize(data.totalUnits, 8);
// Short underline above the total, spanning the UNITS column.
hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9);
page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - w, y, size: 8, font: bold, color: black });
y -= 12;
} }
hline(margin, margin + contentWidth, y); hline(margin, margin + contentWidth, y);
y -= 12; y -= 12;
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString(); const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
const pw = font.widthOfTextAtSize(printedBy, 8); const pw = font.widthOfTextAtSize(printedBy, 8);
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: gray }); page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
return doc.save(); // returns Promise<Uint8Array> return doc.save(); // returns Promise<Uint8Array>
} }
return { buildStudyLoadPdf }; return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName };
}); });
+39 -1
View File
@@ -5,7 +5,7 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
global.PDFLib = require('pdf-lib'); global.PDFLib = require('pdf-lib');
const { buildStudyLoadPdf } = require('../pdf.js'); const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName } = require('../pdf.js');
const sampleData = { const sampleData = {
header: { header: {
@@ -49,4 +49,42 @@ test('buildStudyLoadPdf returns valid PDF bytes', async () => {
const text = extractPdfText(bytes); const text = extractPdfText(bytes);
assert.ok(text.includes('CIS 2101'), 'PDF text should contain course code CIS 2101'); assert.ok(text.includes('CIS 2101'), 'PDF text should contain course code CIS 2101');
assert.ok(text.includes("STUDENT'S STUDY LOAD"), 'PDF text should contain the title'); assert.ok(text.includes("STUDENT'S STUDY LOAD"), 'PDF text should contain the title');
assert.ok(text.includes('CHRISTIAN MADERAZO'), 'PDF text should contain the teacher');
assert.ok(text.includes('BS IT'), 'PDF text should contain the compacted program');
});
test('compactProgram shortens degree names', () => {
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'), 'BS IT');
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN COMPUTER SCIENCE'), 'BS CS');
assert.strictEqual(compactProgram('BACHELOR OF ARTS IN POLITICAL SCIENCE'), 'AB PS');
assert.strictEqual(compactProgram('BACHELOR OF SCIENCE IN ACCOUNTANCY'), 'BS Accountancy');
assert.strictEqual(compactProgram('UNKNOWN PROGRAM'), 'UNKNOWN PROGRAM');
});
test('formatSchedule normalizes schedule text', () => {
assert.strictEqual(formatSchedule('M W 07:30 AM - 10:00 AM LB447TC TC'), 'M,W 07:30 AM - 10:00 AM LB447TC');
assert.strictEqual(formatSchedule('T Th 09:00 AM - 10:30 AM LB443TC TC'), 'T,Th 09:00 AM - 10:30 AM LB443TC');
assert.strictEqual(formatSchedule('M,W 03:00 PM - 05:30 PM LB443TC'), 'M,W 03:00 PM - 05:30 PM LB443TC');
assert.strictEqual(
formatSchedule('T 07:30 AM - 10:30 AM LB448TC TC Th 07:30 AM - 10:30 AM LB448TC'),
'T 07:30 AM - 10:30 AM LB448TC\nTh 07:30 AM - 10:30 AM LB448TC'
);
assert.strictEqual(
formatSchedule('W 01:30 PM - 04:30 PM LB448TC TC M 01:30 PM - 04:30 PM LB448TC'),
'W 01:30 PM - 04:30 PM LB448TC\nM 01:30 PM - 04:30 PM LB448TC'
);
});
test('formatFullName reorders first-last names to last-first', () => {
assert.strictEqual(formatFullName('ADRIAN ALFRED CHIONG BONPIN'), 'BONPIN, ADRIAN ALFRED CHIONG');
assert.strictEqual(formatFullName('BONPIN, ADRIAN ALFRED CHIONG'), 'BONPIN, ADRIAN ALFRED CHIONG');
assert.strictEqual(formatFullName('JOHN DOE'), 'DOE, JOHN');
assert.strictEqual(formatFullName(''), '');
});
test('deriveDateRange maps semesters', () => {
assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY');
assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY');
assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY');
assert.strictEqual(deriveDateRange(''), '');
}); });