feat: restore direct PDF download via background; drop printdoc

This commit is contained in:
adrianbonpin
2026-08-18 19:38:26 +08:00
parent 8bcfee341c
commit 1537881ebc
9 changed files with 304 additions and 197 deletions
+35
View File
@@ -0,0 +1,35 @@
'use strict';
function errMsg(err) {
return String((err && err.message) || err);
}
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'generate-pdf') {
generatePdf(msg.data)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
return true;
}
});
async function generatePdf(data) {
const bytes = await ISMIStools.buildStudyLoadPdf(data);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const name = (data.header && data.header.fullName) || 'Student';
const filename = 'Study Load - ' + name + '.pdf';
try {
const id = await browser.downloads.download({ url, filename, saveAs: true });
const onChanged = (delta) => {
if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) {
browser.downloads.onChanged.removeListener(onChanged);
URL.revokeObjectURL(url);
}
};
browser.downloads.onChanged.addListener(onChanged);
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
+7 -15
View File
@@ -150,25 +150,17 @@
const btn = document.getElementById(BTN_ID);
try {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Opening...';
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
const { header, courses } = await scrapeStudyLoad();
const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included);
const html = ISMIStools.buildStudyLoadHtml({ header, courses: included, totalUnits });
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (!win) {
URL.revokeObjectURL(url);
showToast('Popup blocked — allow popups for this site, then try again.', true);
return;
}
// The blob document runs its own onload → window.print(). Never touch
// win.document here: if the new tab navigates cross-origin, accessing it
// throws "Permission denied".
setTimeout(() => URL.revokeObjectURL(url), 60000);
showToast('Opened printable Study Load.');
const res = await browser.runtime.sendMessage({
type: 'generate-pdf',
data: { header, courses: included, totalUnits }
});
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
showToast('PDF downloaded.');
} catch (err) {
showToast('Error: ' + ((err && err.message) || err), true);
} finally {
@@ -11,10 +11,10 @@ student portal experience. It provides three features:
1. **Auto-login** — auto-fill and auto-submit the login form from a saved profile.
2. **Auto-accept Privacy Policy** — dismiss the recurring privacy-policy modal.
3. **Printable Study Load** — an on-page "Download PDF" button on the Study Load page
that opens a print-ready Letter-size document replicating the official ISMIS Study
Load (with course descriptions looked up from the Offered Courses endpoint),
triggering the browser print dialog so the user can print or "Save as PDF".
3. **Study Load PDF** — an on-page "Download PDF" button on the Study Load page that
generates a PDF replicating the official ISMIS Study Load document (Letter size,
with course descriptions looked up from the Offered Courses endpoint) and
downloads it directly.
A single **master toggle** disables all extension behavior on the portal.
@@ -25,21 +25,29 @@ runs in the content script (scraping and print-document building happen in-page)
```
ismis-helper/
├── manifest.json # MV3 manifest, permissions, content script, popup
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, print flow
├── manifest.json # MV3 manifest, permissions, content script, popup, background
├── background.js # message handler: build PDF (pdf-lib) + download via downloads API
├── content.js # runs on ismis.usc.edu.ph: auto-login, privacy, button, scrape
├── parsers.js # UMD: table/profile/offered-courses parsing + filtering
├── printdoc.js # UMD: buildStudyLoadHtml (Letter-size printable document)
├── pdf.js # UMD: buildStudyLoadPdf (Letter PDF, official layout, pagination)
├── popup.html # popup UI (master toggle + profiles)
├── popup.js # popup logic
├── popup.css # popup styles
├── libs/pdf-lib.min.js # vendored pdf-lib library (used by background)
└── icons/ # extension icons (16, 32, 48, 128)
```
### Permissions (manifest.json)
- `storage` — persist profiles and the master toggle.
- `downloads` — save the generated Study Load PDF.
- `host_permissions` — `https://ismis.usc.edu.ph/*` for the content script.
- No background, no `downloads` permission (the print flow uses `window.open` +
the browser's print dialog).
- Background loads `libs/pdf-lib.min.js` + `pdf.js` via `background.scripts`.
## Background script
- `background.js` handles the `generate-pdf` message from the content script:
builds the PDF via `ISMIStools.buildStudyLoadPdf`, wraps the bytes in a Blob,
downloads via `browser.downloads.download({ saveAs: true })`, and revokes the
object URL when the download reaches `complete`/`interrupted` (and on failure).
## Component 1 — Auto-login
@@ -68,13 +76,14 @@ ismis-helper/
that profile is used for auto-login.
- Master toggle stored under key `enabled` (boolean).
## Component 4 — Printable Study Load
## Component 4 — Study Load PDF
### On-page button
- The content script watches the Study Load page (`#StudyLoad`). When the course
table appears (after the user selects academic period/year and searches), it
injects a **"Download PDF"** button below the `12.00 Units / Total Units: 18.00`
footer, styled like the page's buttons (`btn green pull-right`).
injects a **"Download PDF"** button inline with the
`12.00 Units / Total Units: 18.00` footer (button on the left, units text on the
right, via a flex row), styled like the page's buttons (`btn green`).
### Scrape
- Parse the course table (columns: Group #, Course Code, Schedule, Faculty, Units,
@@ -94,44 +103,43 @@ ismis-helper/
- If lookup fails for a course, the description is left blank and generation
continues.
### Print document
- `buildStudyLoadHtml` (printdoc.js) produces a self-contained HTML document at the
official dimensions (**Letter, 8.5in × 11in** via `@page`), replicating the official
layout: title block (STUDENT'S STUDY LOAD, semester, date range, date enrolled),
student info row (ID, name, program, year level), the course table (GROUP, COURSE,
COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is the status), and a footer
(total units, "Printed by: …").
- The button opens this document in a new window and auto-triggers
`window.print()`, so the user prints or "Save as PDF" at the official dimensions.
- All scraped/entered values are HTML-escaped.
### PDF generation
- `pdf.js` (`buildStudyLoadPdf`, using **pdf-lib**) builds a Letter-size (612×792pt)
PDF replicating the official layout: title block (STUDENT'S STUDY LOAD, semester,
date range, date enrolled), student info row (ID, name, program, year level), the
course table (GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE — NOTE is
the status), and a footer (total units, "Printed by: …"). Multi-page
pagination re-draws the table header. NOTE column is wide enough for `REGULAR`.
- The content script sends the data to the background (`generate-pdf`), which
downloads the PDF via the `downloads` API (`saveAs: true`).
## Data flow
```
Study Load page ──▶ content script (scrape + filter + description lookup)
Study Load page ──▶ content script (scrape + filter DISSOLVED + description lookup)
│
▼ browser.runtime.sendMessage({ type: 'generate-pdf', data })
background.js (pdf-lib buildStudyLoadPdf)
│
▼
buildStudyLoadHtml (printdoc.js)
│
▼
window.open + window.print() → print / Save as PDF (Letter)
browser.downloads.download → Study Load PDF file
```
## Error handling
- Master toggle off → no extension behavior on the portal.
- No enabled profile → no auto-login.
- Description lookup fails for a course → blank description, document still opens.
- No enrolled (non-DISSOLVED) courses → error toast, no window opened.
- Popup blocked → toast explaining popups must be allowed for the site.
- Description lookup fails for a course → blank description, PDF still generated.
- No enrolled (non-DISSOLVED) courses → error toast, no download.
- Download fails → error toast; object URL is revoked.
## Testing
- Node `node:test` suites: parsers (table/profile/offered-courses/filter/sum) and
printdoc (page size, layout, escaping, auto-print).
pdf (valid Letter PDF output).
- Load via `about:debugging` → "Load Temporary Add-on".
- Verify auto-login on the login page.
- Verify privacy modal auto-accept.
- Verify profile add/edit/delete/toggle and master toggle.
- Verify the on-page Download PDF button: appears below the total-units footer,
excludes DISSOLVED courses, and opens the Letter-size printable document.
- Verify the on-page Download PDF button: appears inline with the total-units
footer, excludes DISSOLVED courses, and downloads a Letter-size PDF.
+16
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -1,14 +1,17 @@
{
"manifest_version": 3,
"name": "ISMIS Helper",
"version": "1.1.0",
"description": "Auto-login, auto-accept privacy policy, and printable Study Load for the USC ISMIS portal.",
"permissions": ["storage"],
"version": "1.2.0",
"description": "Auto-login, auto-accept privacy policy, and Study Load PDF for the USC ISMIS portal.",
"permissions": ["storage", "downloads"],
"host_permissions": ["https://ismis.usc.edu.ph/*"],
"background": {
"scripts": ["libs/pdf-lib.min.js", "pdf.js", "background.js"]
},
"content_scripts": [
{
"matches": ["https://ismis.usc.edu.ph/*"],
"js": ["parsers.js", "printdoc.js", "content.js"],
"js": ["parsers.js", "content.js"],
"run_at": "document_idle"
}
],
+146
View File
@@ -0,0 +1,146 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
function getPDFLib() {
return (typeof self !== 'undefined' && self.PDFLib) ||
(typeof globalThis !== 'undefined' && globalThis.PDFLib);
}
function wrapText(text, font, size, maxWidth) {
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
line = candidate;
} else {
if (line) lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines.length ? lines : [''];
}
async function buildStudyLoadPdf(data) {
const PDFLib = getPDFLib();
const { PDFDocument, StandardFonts, rgb } = PDFLib;
const header = data.header || {};
const courses = data.courses || [];
const doc = await PDFDocument.create();
let page = doc.addPage([612, 792]); // Letter
const font = doc.embedStandardFont(StandardFonts.Helvetica);
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
const margin = 50;
const contentWidth = 612 - margin * 2;
let y = 792 - 50;
const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3);
function center(text, size, fontToUse, dy) {
const w = fontToUse.widthOfTextAtSize(text, size);
page.drawText(text, { x: (612 - w) / 2, y: y, size, font: fontToUse, color: black });
y -= dy;
}
function left(text, size, fontToUse, x, dy) {
page.drawText(text, { x, y, size, font: fontToUse, color: black });
y -= dy;
}
// Header
center(header.semester || '', 14, bold, 20);
center("STUDENT'S STUDY LOAD", 16, bold, 24);
center(header.dateRange || '', 11, font, 18);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 10, font, margin, 16);
y -= 6;
if (header.studentId) left('Student ID: ' + header.studentId, 10, font, margin, 14);
if (header.fullName) left('Name: ' + header.fullName, 10, font, margin, 14);
if (header.program) left('Program: ' + header.program, 10, font, margin, 14);
if (header.yearLevel) left('Year Level: ' + header.yearLevel, 10, font, margin, 14);
y -= 10;
// Table columns: NOTE, UNITS, TEACHER, COURSE DESCRIPTION, COURSE, GROUP
const cols = [
{ key: 'status', label: 'NOTE', x: margin, w: 50 },
{ key: 'units', label: 'UNITS', x: margin + 50, w: 45 },
{ key: 'teacher', label: 'TEACHER', x: margin + 95, w: 110 },
{ key: 'description', label: 'COURSE DESCRIPTION', x: margin + 205, w: 150 },
{ key: 'courseCode', label: 'COURSE', x: margin + 355, w: 70 },
{ key: 'groupSchedule', label: 'GROUP', x: margin + 425, w: 87 }
];
const cellSize = 9;
const headerSize = 9;
function drawRow(cells, isHeader) {
const f = isHeader ? bold : font;
const size = isHeader ? headerSize : cellSize;
const linesPerCol = cols.map((c) => wrapText(cells[c.key] || '', f, size, c.w - 6));
const rowHeight = Math.max(...linesPerCol.map((l) => l.length)) * 11 + 8;
if (!isHeader && y - rowHeight < 50) {
// Row doesn't fit on the current page: start a new page and re-draw the header.
page = doc.addPage([612, 792]);
y = 792 - 50;
drawHeaderRow();
}
if (isHeader) {
page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, color: rgb(0.85, 0.9, 0.95) });
}
page.drawRectangle({ x: margin, y: y - rowHeight, width: contentWidth, height: rowHeight, borderColor: black, borderWidth: 0.5 });
for (const c of cols) {
page.drawRectangle({ x: c.x, y: y - rowHeight, width: c.w, height: rowHeight, borderColor: black, borderWidth: 0.5 });
}
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
const lines = linesPerCol[i];
let ty = y - 10;
for (const line of lines) {
page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black });
ty -= 11;
}
}
y -= rowHeight;
return true;
}
function drawHeaderRow() {
const headerCells = {};
cols.forEach((c) => { headerCells[c.key] = c.label; });
drawRow(headerCells, true);
}
drawHeaderRow();
for (const course of courses) {
const cells = {
status: course.status,
units: course.units,
teacher: course.teacher,
description: course.description,
courseCode: course.courseCode,
groupSchedule: (course.group ? course.group + ' ' : '') + (course.schedule || '')
};
if (!drawRow(cells, false)) break;
}
y -= 12;
if (data.totalUnits) {
page.drawText('Total Units: ' + data.totalUnits, { x: margin, y: y, size: 11, font: bold, color: black });
y -= 16;
}
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
page.drawText(printedBy, { x: margin, y: y, size: 9, font, color: gray });
return doc.save(); // returns Promise<Uint8Array>
}
return { buildStudyLoadPdf };
});
-85
View File
@@ -1,85 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ISMIStools = Object.assign(root.ISMIStools || {}, factory());
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Builds a printable HTML document replicating the official ISMIS Study Load
// layout (Letter size, 8.5in x 11in). Auto-triggers window.print() on load so
// the user can print or "Save as PDF" at the official dimensions.
function buildStudyLoadHtml(data) {
const header = data.header || {};
const courses = data.courses || [];
const rows = courses.map((c) =>
'<tr>' +
'<td class="col-g">' + escapeHtml(c.group) + '</td>' +
'<td class="col-c">' + escapeHtml(c.courseCode) + '</td>' +
'<td class="col-d">' + escapeHtml(c.description) +
(c.schedule ? '<div class="sched">' + escapeHtml(c.schedule) + '</div>' : '') +
'</td>' +
'<td class="col-t">' + escapeHtml(c.teacher) + '</td>' +
'<td class="col-u">' + escapeHtml(c.units) + '</td>' +
'<td class="col-n">' + escapeHtml(c.status) + '</td>' +
'</tr>'
).join('\n');
const studentLine = [
header.studentId ? '<b>' + escapeHtml(header.studentId) + '</b>' : '',
escapeHtml(header.fullName || ''),
escapeHtml(header.program || ''),
header.yearLevel ? 'Year Level: ' + escapeHtml(header.yearLevel) : ''
].filter(Boolean).join('&nbsp;&nbsp;&nbsp;');
const semester = header.semester ? '<div class="centered">' + escapeHtml(header.semester) + '</div>\n' : '';
const dateRange = header.dateRange ? '<div class="centered">' + escapeHtml(header.dateRange) + '</div>\n' : '';
const dateEnrolled = header.dateEnrolled
? '<div class="date-enrolled">Date Enrolled: ' + escapeHtml(header.dateEnrolled) + '</div>\n'
: '';
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
return '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n' +
'<title>Student\'s Study Load</title>\n<style>\n' +
'@page { size: 8.5in 11in; margin: 0.45in 0.5in; }\n' +
'html, body { margin: 0; padding: 0; }\n' +
'body { font-family: "Segoe UI", Arial, sans-serif; font-size: 9pt; color: #000; }\n' +
'.title { text-align: center; font-weight: bold; font-size: 9.5pt; margin: 0 0 2pt 0; }\n' +
'.centered { text-align: center; margin: 2pt 0; }\n' +
'.date-enrolled { font-style: italic; margin: 3pt 0; }\n' +
'.student { margin: 4pt 0 8pt 0; }\n' +
'table { width: 100%; border-collapse: collapse; border-bottom: 0.75pt solid #000; }\n' +
'thead th { font-weight: normal; font-size: 8pt; text-align: left; ' +
'border-top: 0.75pt solid #000; border-bottom: 0.75pt solid #000; padding: 1pt 2pt; }\n' +
'td { font-size: 8pt; padding: 1pt 2pt; vertical-align: top; }\n' +
'.col-g { width: 5%; } .col-c { width: 9%; } .col-d { width: 37%; }\n' +
'.col-t { width: 24%; } .col-u { width: 6%; text-align: right; } ' +
'.col-n { width: 19%; text-align: right; }\n' +
'.sched { margin-top: 1pt; }\n' +
'.total-row td { padding-top: 6pt; }\n' +
'</style>\n</head>\n<body>\n' +
'<div class="title">STUDENT\'S STUDY LOAD</div>\n' +
semester + dateRange + dateEnrolled +
'<div class="student">' + studentLine + '</div>\n' +
'<table>\n<thead><tr>' +
'<th>GROUP</th><th>COURSE</th><th>COURSE DESCRIPTION</th><th>TEACHER</th><th>UNITS</th><th>NOTE</th>' +
'</tr></thead>\n<tbody>\n' + rows + '\n' +
'<tr class="total-row"><td colspan="6" style="text-align:right;">' +
escapeHtml(data.totalUnits || '') +
'</td></tr>\n</tbody>\n</table>\n' +
'<div style="text-align:right; margin-top:3pt; font-size:8pt;">' + escapeHtml(printedBy) + '</div>\n' +
'<script>window.onload = function () { window.focus(); window.print(); };</script>\n' +
'</body>\n</html>';
}
return { buildStudyLoadHtml };
});
+52
View File
@@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
global.PDFLib = require('pdf-lib');
const { buildStudyLoadPdf } = require('../pdf.js');
const sampleData = {
header: {
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
yearLevel: '4',
semester: '1ST Semester 2026',
dateEnrolled: '8/5/2024',
dateRange: 'AUGUST - JANUARY'
},
courses: [
{ group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' },
{ group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' }
],
totalUnits: '6.00'
};
function extractPdfText(bytes) {
const tmp = path.join(os.tmpdir(), 'study-load-test.pdf');
fs.writeFileSync(tmp, Buffer.from(bytes));
const script = [
'import fitz',
`doc = fitz.open('${tmp}')`,
'text = ""',
'for page in doc:',
' text += page.get_text()',
'print(text)'
].join('\n');
return execFileSync('python3', ['-c', script], { encoding: 'utf8' });
}
test('buildStudyLoadPdf returns valid PDF bytes', async () => {
const bytes = await buildStudyLoadPdf(sampleData);
assert.ok(bytes instanceof Uint8Array);
const head = Buffer.from(bytes.slice(0, 5)).toString('latin1');
assert.strictEqual(head, '%PDF-');
const doc = await PDFLib.PDFDocument.load(bytes);
assert.strictEqual(doc.getPageCount(), 1);
const text = extractPdfText(bytes);
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');
});
-60
View File
@@ -1,60 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { buildStudyLoadHtml } = require('../printdoc.js');
const sampleData = {
header: {
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
yearLevel: '4',
semester: '1ST Semester 2026',
dateEnrolled: '8/5/2024',
dateRange: 'AUGUST - JANUARY'
},
courses: [
{ group: '7', courseCode: 'CIS 2101', description: 'DATA STRUCTURES AND ALGORITHMS', units: '3.00', teacher: 'CHRISTIAN MADERAZO', schedule: 'M,W 03:00 PM - 05:30 PM LB443TC', status: 'REGULAR' },
{ group: '2', courseCode: 'IT 3103N', description: 'SYSTEMS INTEGRATION AND ARCHITECTURE', units: '3.00', teacher: 'KHENT DELA PAZ', schedule: 'T,Th 01:30 PM - 03:00 PM DCISMVR3TC', status: 'REGULAR' }
],
totalUnits: '6.00'
};
test('buildStudyLoadHtml uses Letter page size', () => {
const html = buildStudyLoadHtml(sampleData);
assert.match(html, /@page\s*\{[^}]*size:\s*8\.5in\s*11in/i);
});
test('buildStudyLoadHtml includes title, student info, and table headers', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(html.includes("STUDENT'S STUDY LOAD"));
assert.ok(html.includes('ADRIAN ALFRED CHIONG BONPIN'));
assert.ok(html.includes('22103625'));
assert.ok(html.includes('Year Level: 4'));
for (const h of ['GROUP', 'COURSE', 'COURSE DESCRIPTION', 'TEACHER', 'UNITS', 'NOTE']) {
assert.ok(html.includes(h), 'missing header: ' + h);
}
});
test('buildStudyLoadHtml includes course data, schedule, and total', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(html.includes('CIS 2101'));
assert.ok(html.includes('DATA STRUCTURES AND ALGORITHMS'));
assert.ok(html.includes('M,W 03:00 PM - 05:30 PM LB443TC'));
assert.ok(html.includes('6.00'));
});
test('buildStudyLoadHtml escapes user content', () => {
const html = buildStudyLoadHtml({
header: { fullName: '<script>alert(1)</script>' },
courses: [{ group: '', courseCode: 'A&B', description: 'x"y', units: '1', teacher: '', schedule: '', status: 'R' }],
totalUnits: '1.00'
});
assert.ok(!html.includes('<script>alert(1)</script>'), 'script must be escaped');
assert.ok(html.includes('&lt;script&gt;'), 'script must appear escaped');
assert.ok(html.includes('A&amp;B'), 'ampersand must be escaped');
});
test('buildStudyLoadHtml auto-prints on load', () => {
const html = buildStudyLoadHtml(sampleData);
assert.ok(/window\.print\(\)/.test(html), 'must call window.print()');
});