feat(v1.4.0): exports + smart year level + progress + encrypted passwords
ci / test (push) Successful in 43s
build-extension / build (push) Successful in 6m7s

v1.4.0: CSV/Excel/JSON/NextUp exports, units-first smart year standing, live export progress bar, AES-GCM at-rest password encryption.
This commit was merged in pull request #4.
This commit is contained in:
2026-08-20 04:39:08 +00:00
parent 89fd587c21
commit 0cb70ecee0
19 changed files with 5442 additions and 91 deletions
+102
View File
@@ -0,0 +1,102 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
buildStudyLoadCsv, buildNextUpCsv, parseScheduleBlocks, csvField
} = require('../export-csv.js');
const { buildStudyLoadJson } = require('../export-json.js');
const { buildStudyLoadXlsx } = require('../export-xlsx.js');
const data = {
year: '2024',
period: '1ST Semester',
totalUnits: '21.00',
header: {
program: 'BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY',
fullName: 'ADRIAN ALFRED CHIONG BONPIN',
studentId: '22103625',
semester: '1ST Semester 2024',
yearLevel: '3'
},
courses: [
{ group: '1', courseCode: 'IT 3101N', description: 'PLATFORM TECHNOLOGIES',
schedule: 'F 01:30 PM - 04:30 PM LB486', faculty: 'TAN', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CIS 1101', description: 'PROGRAMMING I',
schedule: 'T Th 03:00 PM - 04:30 PM LB468', faculty: '', units: '3.0', status: 'REGULAR' },
{ group: '1', courseCode: 'CS 5105', description: 'COMP, TALKS "X"',
schedule: '', faculty: '', units: '3.00', status: 'DISSOLVED' }
]
};
// ---- CSV ----
test('buildStudyLoadCsv includes header block, table, and total', () => {
const csv = buildStudyLoadCsv(data);
assert.ok(csv.startsWith('\uFEFF'), 'has UTF-8 BOM');
assert.ok(csv.includes('Program,BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY'));
assert.ok(csv.includes('Student ID,22103625'));
assert.ok(csv.includes('Group,Course Code,Description,Schedule,Faculty,Units,Status'));
assert.ok(csv.includes('1,IT 3101N,PLATFORM TECHNOLOGIES,F 01:30 PM - 04:30 PM LB486,TAN,3.0,REGULAR'));
assert.ok(/Total,.*21\.00/.test(csv));
});
test('csvField quotes fields containing commas, quotes, newlines', () => {
assert.strictEqual(csvField('plain'), 'plain');
assert.strictEqual(csvField('a,b'), '"a,b"');
assert.strictEqual(csvField('say "hi"'), '"say ""hi"""');
});
// ---- NextUp ----
test('buildNextUpCsv matches the Next Up column contract', () => {
const csv = buildNextUpCsv(data).replace(/^\uFEFF/, '');
const header = csv.split('\r\n')[0];
assert.strictEqual(header,
'title,courseCode,days,startTime,endTime,room,meetingLink,color,icon,units,classLink,professorName,professorEmail,groupName');
// Schedule blocks -> one row per block, day codes and 24h times.
assert.ok(csv.includes('PLATFORM TECHNOLOGIES,IT 3101N,FR,13:30,16:30,LB486'));
assert.ok(csv.includes('PROGRAMMING I,CIS 1101,"TU,TH",15:00,16:30,LB468'));
});
test('parseScheduleBlocks splits multi-block schedules', () => {
const blocks = parseScheduleBlocks('M 08:00 AM - 09:00 AM R1; W 10:00 AM - 12:00 PM R2');
assert.ok(Array.isArray(blocks) && blocks.length >= 1);
assert.strictEqual(Math.max(blocks.length, 0) > 0, true);
});
// ---- JSON ----
test('buildStudyLoadJson has improved, clean schema', () => {
const j = buildStudyLoadJson(data);
assert.strictEqual(j.meta.program, data.header.program);
assert.strictEqual(j.meta.studentName, data.header.fullName);
assert.strictEqual(j.meta.academicYear, '2024');
assert.strictEqual(j.meta.yearLevel, '3');
assert.strictEqual(j.meta.totalUnits, '21.00');
assert.ok(j.meta.generatedAt && /ISO|Z|T/.test(j.meta.generatedAt));
assert.strictEqual(j.courses.length, 3);
assert.strictEqual(j.courses[0].courseCode, 'IT 3101N');
assert.deepStrictEqual(j.totals, { enrolledUnits: '21.00' });
});
// ---- XLSX ----
test('buildStudyLoadXlsx returns a valid ZIP with expected parts', () => {
const bytes = buildStudyLoadXlsx(data);
assert.ok(bytes instanceof Uint8Array);
// A valid ZIP starts with PK (0x50 0x4B).
assert.strictEqual(bytes[0], 0x50);
assert.strictEqual(bytes[1], 0x4b);
// Contains XML as text (spot check for the sheet + shared strings).
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('Content_Types'), 'content types part present');
assert.ok(asText.includes('sharedStrings'), 'shared strings part present');
assert.ok(asText.includes('ISMIS HELPER - STUDY LOAD'), 'title present');
});
test('buildStudyLoadXlsx embeds course data as shared strings', () => {
const bytes = buildStudyLoadXlsx(data);
const asText = Buffer.from(bytes).toString('latin1');
assert.ok(asText.includes('PLATFORM TECHNOLOGIES'));
assert.ok(asText.includes('IT 3101N'));
assert.ok(asText.includes('21.00'));
});
+165
View File
@@ -0,0 +1,165 @@
const { test } = require('node:test');
const assert = require('node:assert');
const {
parseGradeHistory, yearLevelForSemester,
parseProspectusYearUnits, parseLackingSubjects, parseLackingSubjectsUnits,
computeYearStandingFromUnits
} = require('../parsers.js');
// ---- View Grades history ----
test('parseGradeHistory extracts ordered semester terms', () => {
const html = `
<h3>FIRST SEMESTER - 2022</h3><table>...</table>
<h3>SECOND SEMESTER - 2022</h3><table>...</table>
<h3>FIRST SEMESTER - 2023</h3><table>...</table>
<h3>SUMMER - 2025</h3><table>...</table>
<h3>FIRST SEMESTER - 2026</h3><table>...</table>
`;
const h = parseGradeHistory(html);
assert.deepStrictEqual(h, [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SUMMER', year: 2025 },
{ semester: 'FIRST', year: 2026 }
]);
});
test('parseGradeHistory handles empty input', () => {
assert.deepStrictEqual(parseGradeHistory(''), []);
});
test('yearLevelForSemester returns standing from history position', () => {
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 },
{ semester: 'FIRST', year: 2026 }
];
// 2022 -> Y1, 2023 -> Y2, 2024 -> Y3, 2025 -> Y4, 2026 -> Y5
assert.strictEqual(yearLevelForSemester(history, 2022), 1);
assert.strictEqual(yearLevelForSemester(history, 2023), 2);
assert.strictEqual(yearLevelForSemester(history, 2024), 3);
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
assert.strictEqual(yearLevelForSemester(history, 2026), 5);
// Summer of 2025 is the same standing as 2025 -> Year 4 (period is not
// passed in; the standing is purely the history position).
assert.strictEqual(yearLevelForSemester(history, 2025), 4);
});
test('yearLevelForSemester clamps to program length and handles gaps', () => {
// History ends at 2025; a query beyond it must not exceed the final level.
const history = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2023 },
{ semester: 'SECOND', year: 2023 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 },
{ semester: 'FIRST', year: 2025 },
{ semester: 'SECOND', year: 2025 }
];
assert.strictEqual(yearLevelForSemester(history, 2028), 4); // clamped
// Gap year: a skipped academic year still advances standing.
const gap = [
{ semester: 'FIRST', year: 2022 },
{ semester: 'SECOND', year: 2022 },
{ semester: 'FIRST', year: 2024 },
{ semester: 'SECOND', year: 2024 }
];
assert.strictEqual(yearLevelForSemester(gap, 2024), 2);
});
test('yearLevelForSemester handles missing year and empty history', () => {
assert.strictEqual(yearLevelForSemester([], 2024), null);
assert.strictEqual(yearLevelForSemester([{ semester: 'FIRST', year: 2022 }], NaN), null);
});
// ---- Prospectus ----
test('parseProspectusYearUnits totals units per year level', () => {
const html = `
<h3>Year Level: 1</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 1101</td><td>PROGRAMMING I</td><td>3.0</td></tr>
<tr><td>CIS 1102</td><td>INTRO</td><td>3.0</td></tr>
</table>
<h3>Year Level: 2</h3>
<table><tr><th>Course</th></tr>
<tr><td>CIS 2101</td><td>DATA STRUCTURES</td><td>3.0</td></tr>
</table>
<h3>Year Level: 3</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 3101N</td><td>PLATFORM</td><td>4.0</td></tr>
</table>
<h3>Year Level: 4</h3>
<table><tr><th>Course</th></tr>
<tr><td>IT 4101</td><td>CAPSTONE</td><td>2.0</td></tr>
</table>
`;
const y = parseProspectusYearUnits(html);
assert.deepStrictEqual(y, [
{ year: 1, units: 6 },
{ year: 2, units: 3 },
{ year: 3, units: 4 },
{ year: 4, units: 2 }
]);
});
// ---- Lacking subjects ----
test('parseLackingSubjects returns remaining units and courses', () => {
const html = `
<table>
<tr><th>Course Code</th><th>Description</th><th>Units</th></tr>
<tr><td>IT 3206</td><td>INTEGRATIVE PROGRAMMING</td><td>3.0</td></tr>
<tr><td>IT FREE EL</td><td>FREE ELECTIVE</td><td>3.0</td></tr>
<tr><td>IT 4202N</td><td>PRACTICUM 2</td><td>6.0</td></tr>
</table>
`;
const l = parseLackingSubjects(html);
assert.strictEqual(l.units, '12.00');
assert.strictEqual(l.courses.length, 3);
assert.strictEqual(parseLackingSubjectsUnits(html), '12.00');
});
test('parseLackingSubjects handles empty input', () => {
assert.strictEqual(parseLackingSubjectsUnits(''), '0.00');
});
// ---- Units math ----
test('computeYearStandingFromUnits maps finished units to year standing', () => {
// Cumulative 52/99/148/181 (matches the real BSIT prospectus).
const yearUnits = [
{ year: 1, units: 52 },
{ year: 2, units: 47 },
{ year: 3, units: 49 },
{ year: 4, units: 33 }
];
// finished = 181 - 24 = 157 -> Year 4 (real user data).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 24), 4);
// No lacking -> finished everything -> final year.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 0), 4);
// finished 40 -> still Year 1 (< 52).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 141), 1);
// finished 60 -> Year 2 (52 <= 60 < 99).
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 121), 2);
// exact boundary: finished 52 -> Year 2.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 129), 2);
// finished 99 -> Year 3.
assert.strictEqual(computeYearStandingFromUnits(yearUnits, 82), 3);
});
test('computeYearStandingFromUnits handles empty and negative', () => {
assert.strictEqual(computeYearStandingFromUnits([], 5), null);
// lacking > total -> clamps to first year.
assert.strictEqual(computeYearStandingFromUnits([{ year: 1, units: 10 }], 99), 1);
});