1
0

test: Added xlsx parsing tests
Remote Deploy / deploy (push) Successful in 1m25s

This commit is contained in:
2026-06-04 11:52:41 +02:00
parent 1c6023beab
commit e94c43ca6e
9 changed files with 1724 additions and 367 deletions
+1231 -8
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -7,7 +7,8 @@
"type": "module", "type": "module",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"test": "tsx tests/test.ts", "test": "vitest run",
"test:watch": "vitest",
"start": "concurrently \"NODE_ENV=development tsx server.ts\" \"NODE_ENV=development tsx cron-runner.ts\"", "start": "concurrently \"NODE_ENV=development tsx server.ts\" \"NODE_ENV=development tsx cron-runner.ts\"",
"build": "tsc && cd web && hugo --gc --minify && cd ../viewer && npm run build", "build": "tsc && cd web && hugo --gc --minify && cd ../viewer && npm run build",
"build-noweb": "tsc", "build-noweb": "tsc",
@@ -46,6 +47,7 @@
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.1.8"
} }
} }
+14
View File
@@ -1,3 +1,17 @@
/*
* Copyright (C) 2026 Jakub Žitník
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
const API_BASE_URL = process.env.ANNOUNCEMENT_API || "http://localhost:3000"; const API_BASE_URL = process.env.ANNOUNCEMENT_API || "http://localhost:3000";
export type Announcement = { export type Announcement = {
+4 -5
View File
@@ -39,7 +39,7 @@ interface ResolvedDay {
/** /**
* Read theme colors from the workbook * Read theme colors from the workbook
*/ */
async function getThemeColors(filePath: string): Promise<ThemeColors | null> { export async function getThemeColors(filePath: string): Promise<ThemeColors | null> {
const data = fs.readFileSync(filePath); const data = fs.readFileSync(filePath);
const zip = await JSZip.loadAsync(data); const zip = await JSZip.loadAsync(data);
@@ -164,10 +164,9 @@ export default async function parseV3(workbook: Workbook, downloadedFilePath: st
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// //
function getUpcomingSheets(workbook: ExcelJS.Workbook): ResolvedDay[] { export function getUpcomingSheets(workbook: ExcelJS.Workbook, today: Date = new Date()): ResolvedDay[] {
const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.+\s*(\d{1,2})\.+\s*(\d{4}|\d{2})/i; const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.+\s*(\d{1,2})\.+\s*(\d{4}|\d{2})/i;
const today = new Date();
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const result: ResolvedDay[] = []; const result: ResolvedDay[] = [];
@@ -190,7 +189,7 @@ function getUpcomingSheets(workbook: ExcelJS.Workbook): ResolvedDay[] {
return result; return result;
} }
function groupSheetsByDate(items: ResolvedDay[]) { export function groupSheetsByDate(items: ResolvedDay[]) {
const map: Record<string, Worksheet[]> = {}; const map: Record<string, Worksheet[]> = {};
for (const item of items) { for (const item of items) {
@@ -214,7 +213,7 @@ function groupSheetsByDate(items: ResolvedDay[]) {
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// //
function extractDaySchedule(sheet: Worksheet, teacherMap: Record<string, string>, themeColors: ThemeColors | null, flags: Flag[]) { export function extractDaySchedule(sheet: Worksheet, teacherMap: Record<string, string>, themeColors: ThemeColors | null, flags: Flag[]) {
return { return {
changes: extractClassChanges(sheet, themeColors, flags), changes: extractClassChanges(sheet, themeColors, flags),
absence: extractAbsence(sheet, teacherMap), absence: extractAbsence(sheet, teacherMap),
Binary file not shown.
+353
View File
@@ -0,0 +1,353 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import parseAbsence from "../scrape/utils/parseAbsence.js";
const teachermap: Record<string, string> = JSON.parse(
fs.readFileSync("./tests/content/teachermap.json", "utf8"),
);
const cases: [string, any[]][] = [
[
"Me",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
],
],
[
"ad",
[
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
],
],
[
"me ad",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
],
],
[
"me 3",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "single",
hours: 3,
},
],
],
[
"ad 1",
[
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "single",
hours: 1,
},
],
],
[
"me 2-4",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 2, to: 4 },
},
],
],
[
"ad 5,6",
[
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 5, to: 6 },
},
],
],
[
"me 7+",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 7, to: 10 },
},
],
],
[
"me,ad 3-5",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 3, to: 5 },
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 3, to: 5 },
},
],
],
[
"me;ad 4",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "single",
hours: 4,
},
],
],
[
"me;ad;bo 2-3",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
{
teacher: "Ing. Anna Bodnárová",
teacherCode: "bo",
type: "range",
hours: { from: 2, to: 3 },
},
],
],
[
"me 2 ad 4-5",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "single",
hours: 2,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 4, to: 5 },
},
],
],
[
"3",
[
{
type: "invalid",
teacher: null,
teacherCode: null,
hours: null,
original: "3",
},
],
],
[
"2-4",
[
{
type: "invalid",
teacher: null,
teacherCode: null,
hours: null,
original: "2-4",
},
],
],
[
"me Xx 3",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: null,
teacherCode: "xx",
type: "single",
hours: 3,
},
],
],
[
"me,ad;bo 1-2 ad 5+",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
{
teacher: "Ing. Anna Bodnárová",
teacherCode: "bo",
type: "range",
hours: { from: 1, to: 2 },
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 5, to: 10 },
},
],
],
[
"Me-exk",
[
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "exkurze",
hours: null,
},
],
],
[
"za Vn zastupuje Jk",
[
{
teacher: "Ing. Zdeněk Vondra",
teacherCode: "vn",
type: "zastoupen",
hours: null,
zastupuje: {
teacher: "David Janoušek",
teacherCode: "jk",
},
},
],
],
[
"Vc 1. h",
[
{
teacher: "Ing. Antonín Vobecký",
teacherCode: "vc",
type: "single",
hours: 1,
},
],
],
[
"Sv-5-exk",
[
{
teacher: "Ing. Jan Šváb",
teacherCode: "sv",
type: "exkurze",
hours: { from: 5, to: 10 },
},
],
],
[
"Ex-exk. 3+",
[
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: { from: 3, to: 10 },
},
],
],
[
"Ex-exk.3+",
[
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: { from: 3, to: 10 },
},
],
],
[
"Ex-exk. 3",
[
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: 3,
},
],
],
[
"Su 4 - 6",
[
{
teacher: "MUDr. Kristina Studénková",
teacherCode: "su",
type: "range",
hours: { from: 4, to: 6 },
},
],
],
];
function expectSameItems(actual: any[], expected: any[]) {
expect(actual).toHaveLength(expected.length);
for (const item of expected) {
expect(actual).toContainEqual(item);
}
}
describe("parseAbsence", () => {
it.each(cases)("parseAbsence(%s) → %j", (input, expected) => {
expectSameItems(parseAbsence(input, teachermap), expected);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeAll } from "vitest";
import fs from "fs";
import ExcelJS from "exceljs";
import {
getThemeColors,
getUpcomingSheets,
groupSheetsByDate,
extractDaySchedule,
} from "../scrape/parse/v3.js";
const XLSX_PATH = "tests/content/1.xlsx";
const TEACHERMAP_PATH = "tests/content/teachermap.json";
let workbook: ExcelJS.Workbook;
let teachermap: Record<string, string>;
beforeAll(async () => {
workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(XLSX_PATH);
teachermap = JSON.parse(fs.readFileSync(TEACHERMAP_PATH, "utf8"));
});
describe("sheet filtering", () => {
it("includes sheets on or after the injected date", () => {
const injected = new Date(2026, 5, 2); // June 2, 2026
const sheets = getUpcomingSheets(workbook, injected);
expect(sheets.length).toBeGreaterThanOrEqual(1);
expect(sheets.map((s) => s.dateKey)).toContain("2026-06-02");
});
it("excludes sheets before the injected date", () => {
const injected = new Date(2026, 5, 2);
const sheets = getUpcomingSheets(workbook, injected);
for (const s of sheets) {
const [y, m, d] = s.dateKey.split("-").map(Number);
expect(new Date(y, m - 1, d).getTime()).toBeGreaterThanOrEqual(
injected.getTime(),
);
}
});
});
describe("full day parsing", () => {
let result: ReturnType<typeof extractDaySchedule>;
let sheet: ExcelJS.Worksheet;
let themeColors: Awaited<ReturnType<typeof getThemeColors>>;
beforeAll(async () => {
const injected = new Date(2026, 5, 2);
const upcoming = getUpcomingSheets(workbook, injected);
const resolved = groupSheetsByDate(upcoming);
const day = resolved.find((d) => d.dateKey === "2026-06-02")!;
sheet = day.sheet;
themeColors = await getThemeColors(XLSX_PATH);
result = extractDaySchedule(sheet, teachermap, themeColors, []);
});
describe("changes", () => {
it("is a non-empty record of class → lessons", () => {
const keys = Object.keys(result.changes);
expect(keys.length).toBeGreaterThan(0);
for (const [cls, lessons] of Object.entries(result.changes)) {
expect(cls).toMatch(/^[AEC][0-4][a-c]?$/);
expect(lessons).toHaveLength(10);
for (const lesson of lessons) {
if (lesson === null) continue;
expect(lesson).toHaveProperty("text");
expect(typeof lesson.text).toBe("string");
expect(lesson).toHaveProperty("backgroundColor");
}
}
});
it("includes A1a with known lesson at hour 5", () => {
const a1a = result.changes["A1a"];
expect(a1a).toBeDefined();
const hour5 = a1a[4];
expect(hour5).not.toBeNull();
expect(hour5!.text).toContain("ZE");
});
});
describe("absence", () => {
it("is an array of parsed absence entries", () => {
expect(Array.isArray(result.absence)).toBe(true);
expect(result.absence.length).toBeGreaterThan(0);
for (const entry of result.absence) {
expect(entry).toHaveProperty("teacher");
expect(entry).toHaveProperty("teacherCode");
expect(entry).toHaveProperty("type");
expect(entry).toHaveProperty("hours");
}
});
it("contains a known absence entry", () => {
const su = result.absence.find((a: any) => a.teacherCode === "su");
expect(su).toBeDefined();
expect(su!.teacher).toContain("Studénková");
});
});
describe("takesPlace", () => {
it("is a non-empty string", () => {
expect(typeof result.takesPlace).toBe("string");
expect(result.takesPlace.length).toBeGreaterThan(0);
});
});
describe("reservedRooms", () => {
it("is an array of 10 entries", () => {
expect(result.reservedRooms).toHaveLength(10);
});
});
});
-352
View File
@@ -1,352 +0,0 @@
/*
* Copyright (C) 2025 Jakub Žitník
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
import fs from "fs";
import parseAbsence from "../scrape/utils/parseAbsence.js";
const teachermap: Record<string, string> = JSON.parse(fs.readFileSync("./tests/teachermap.json", "utf8"));
let passedAll = true;
test("Me", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
]);
test("ad", [
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
]);
test("me ad", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
]);
test("me 3", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "single",
hours: 3,
},
]);
test("ad 1", [
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "single",
hours: 1,
},
]);
test("me 2-4", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 2, to: 4 },
},
]);
test("ad 5,6", [
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 5, to: 6 },
},
]);
test("me 7+", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 7, to: 10 },
},
]);
test("me,ad 3-5", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "range",
hours: { from: 3, to: 5 },
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 3, to: 5 },
},
]);
test("me;ad 4", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "single",
hours: 4,
},
]);
test("me;ad;bo 2-3", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
{
teacher: "Ing. Anna Bodnárová",
teacherCode: "bo",
type: "range",
hours: { from: 2, to: 3 },
},
]);
test("me 2 ad 4-5", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "single",
hours: 2,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 4, to: 5 },
},
]);
test("3", [
{
type: "invalid",
teacher: null,
teacherCode: null,
hours: null,
original: "3",
},
]);
test("2-4", [
{
type: "invalid",
teacher: null,
teacherCode: null,
hours: null,
original: "2-4",
},
]);
test("me Xx 3", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{ teacher: null, teacherCode: "xx", type: "single", hours: 3 },
]);
test("me,ad;bo 1-2 ad 5+", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "wholeDay",
hours: null,
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "wholeDay",
hours: null,
},
{
teacher: "Ing. Anna Bodnárová",
teacherCode: "bo",
type: "range",
hours: { from: 1, to: 2 },
},
{
teacher: "Bc. Daniel Adámek",
teacherCode: "ad",
type: "range",
hours: { from: 5, to: 10 },
},
]);
test("Me-exk", [
{
teacher: "Michaela Meitnerová",
teacherCode: "me",
type: "exkurze",
hours: null,
},
]);
test("za Vn zastupuje Jk", [
{
teacher: "Ing. Zdeněk Vondra",
teacherCode: "vn",
type: "zastoupen",
hours: null,
zastupuje: {
teacher: "David Janoušek",
teacherCode: "jk",
},
},
]);
test("Vc 1. h", [
{
teacher: "Ing. Antonín Vobecký",
teacherCode: "vc",
type: "single",
hours: 1,
},
]);
test("Sv-5-exk", [
{
teacher: "Ing. Jan Šváb",
teacherCode: "sv",
type: "exkurze",
hours: { from: 5, to: 10 },
},
]);
test("Ex-exk. 3+", [
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: { from: 3, to: 10 },
},
]);
test("Ex-exk.3+", [
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: { from: 3, to: 10 },
},
]);
test("Ex-exk. 3", [
{
teacher: "Ing. Jana Exnerová",
teacherCode: "ex",
type: "exkurze",
hours: 3,
},
]);
test("Su 4 - 6", [
{
teacher: "MUDr. Kristina Studénková",
teacherCode: "su",
type: "range",
hours: {from: 4, to: 6},
}
]);
function test(input: string, expectedOutput: any[]) {
const res = parseAbsence(input, teachermap);
if (!deepEqual(res, expectedOutput)) {
passedAll = false;
console.error("ERROR for input: " + input);
console.log(JSON.stringify(res, null, 2));
console.log(JSON.stringify(expectedOutput, null, 2));
console.log();
}
}
function deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (
typeof a !== "object" ||
a === null ||
typeof b !== "object" ||
b === null
) {
return false;
}
// Handle arrays (ignore order)
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
const used = new Array(b.length).fill(false);
return a.every((itemA) =>
b.some((itemB, i) => {
if (used[i]) return false; // don't reuse elements
if (deepEqual(itemA, itemB)) {
used[i] = true; // mark element as used
return true;
}
return false;
}),
);
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key]));
}
if (passedAll) {
console.log("All tests passed");
}