diff --git a/apps/cli/src/tools/populate-catalog.ts b/apps/cli/src/tools/populate-catalog.ts index 3e1a1a73..490cd981 100644 --- a/apps/cli/src/tools/populate-catalog.ts +++ b/apps/cli/src/tools/populate-catalog.ts @@ -19,7 +19,15 @@ import { defineCommand } from "citty"; import { brandIntro, isVerbose, p, pc, setVerbosity } from "../ui"; import { getDb } from "@sneu/db/pg"; import { catalogMajorsT, catalogMinorsT } from "@sneu/db/schema"; -import { chunk } from "../../../../packages/scraper/src/upload/types"; + +export function chunk(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + /** * Recursively find all files matching a given filename within a directory. */ diff --git a/apps/searchneu/app/graduate/page.tsx b/apps/searchneu/app/graduate/page.tsx index f98ec26f..4a50b9a4 100644 --- a/apps/searchneu/app/graduate/page.tsx +++ b/apps/searchneu/app/graduate/page.tsx @@ -1,12 +1,87 @@ +import { GuestHeaderClient } from "@/components/graduate/GuestHeaderClient"; +import { GuestPlanClient } from "@/components/graduate/GuestPlanClient"; import NewPlanModal from "@/components/graduate/modal/NewPlanModal"; import { auth } from "@/lib/auth/auth"; +import { getAuditPlans } from "@/lib/dal/audits"; +import { getMajor, getMinor } from "@/lib/dal/catalog"; +import { getCourseNamesBatch } from "@/lib/dal/courses"; +import { Requirement } from "@/lib/graduate/types"; import { headers } from "next/headers"; +import { redirect } from "next/navigation"; -export default async function Page() { +function collectCourseKeys(reqs: Requirement[], out: Set): void { + for (const req of reqs) { + if (req.type === "COURSE") { + out.add(`${req.subject}-${req.classId}`); + } else if (req.type === "AND" || req.type === "OR" || req.type === "XOM") { + collectCourseKeys(req.courses, out); + } else if (req.type === "SECTION") { + collectCourseKeys(req.requirements, out); + } + } +} + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ + majors?: string | string[]; + minors?: string | string[]; + catalogYear?: string; + courses?: string; + }>; +}) { const session = await auth.api.getSession({ headers: await headers() }); + + if (session) { + const plans = await getAuditPlans(session.user.id); + if (plans.length > 0) { + const lastModified = plans.reduce((latest, plan) => + plan.updatedAt > latest.updatedAt ? plan : latest, + ); + redirect(`/graduate/${lastModified.id}`); + } + return ( +
+ +
+ ); + } + + const params = await searchParams; + const toArray = (v: string | string[] | undefined): string[] => { + if (!v) return []; + return Array.isArray(v) ? v.filter(Boolean) : [v]; + }; + const majorNames = toArray(params.majors); + const minorNames = toArray(params.minors); + const catalogYear = params.catalogYear ? Number(params.catalogYear) : null; + const scheduleCourseKeys = params.courses + ? params.courses.split(",").filter(Boolean) + : []; + + let courseNames: Record = {}; + if (catalogYear) { + const [majors, minors] = await Promise.all([ + Promise.all(majorNames.map((m) => getMajor(catalogYear, m))), + Promise.all(minorNames.map((m) => getMinor(catalogYear, m))), + ]); + + const keys = new Set(scheduleCourseKeys); + for (const m of [...majors, ...minors]) { + if (!m) continue; + for (const section of m.requirementSections) { + collectCourseKeys(section.requirements, keys); + } + } + + courseNames = await getCourseNamesBatch(keys); + } + return ( -
- +
+ +
); } diff --git a/apps/searchneu/components/graduate/BasePlanClient.tsx b/apps/searchneu/components/graduate/BasePlanClient.tsx index 62686b57..ce30bb38 100644 --- a/apps/searchneu/components/graduate/BasePlanClient.tsx +++ b/apps/searchneu/components/graduate/BasePlanClient.tsx @@ -17,7 +17,6 @@ import { Major, Minor, SeasonEnum, - StatusEnum, Whiteboard, } from "@/lib/graduate/types"; import { @@ -28,7 +27,11 @@ import { collisionAlgorithm, nextId, DELETE_ZONE_ID, + addYear, + deleteYear, + removeCourse, } from "@/lib/graduate/planUtils"; +import { pruneWhiteboard } from "@/lib/graduate/requirementUtils"; import { CourseNameContext } from "./CourseNameContext"; import { CourseDetailsContext } from "./CourseDetailsContext"; import type { CourseDetails } from "@/lib/graduate/types"; @@ -79,30 +82,6 @@ export function BasePlanClient({ "whiteboard", ); - /** Collect all "SUBJECT CLASSID" keys present in a schedule. */ - function scheduleCourseKeys(audit: Audit): Set { - const keys = new Set(); - for (const y of audit.years) { - for (const t of [y.fall, y.spring, y.summer1, y.summer2]) { - for (const c of t.classes) keys.add(`${c.subject} ${c.classId}`); - } - } - return keys; - } - - /** Remove whiteboard courses that no longer exist in the schedule. */ - function pruneWhiteboard(audit: Audit, wb: Whiteboard): Whiteboard | null { - const valid = scheduleCourseKeys(audit); - let changed = false; - const pruned: Whiteboard = {}; - for (const [section, entry] of Object.entries(wb)) { - const filtered = entry.courses.filter((k) => valid.has(k)); - if (filtered.length !== entry.courses.length) changed = true; - pruned[section] = { ...entry, courses: filtered }; - } - return changed ? pruned : null; - } - function persist(updated: Audit) { const withIds = assignDndIds(updated); setSchedule(withIds); @@ -239,32 +218,11 @@ export function BasePlanClient({ season: SeasonEnum, courseIndex: number, ) { - const updated = produce(schedule, (draft) => { - const year = draft.years.find((y) => y.year === yearNum); - if (!year) return; - const termMap: Record = { - [SeasonEnum.FL]: year.fall, - [SeasonEnum.SP]: year.spring, - [SeasonEnum.S1]: year.summer1, - [SeasonEnum.S2]: year.summer2, - }; - const term = termMap[season]; - if (term) { - term.classes.splice(courseIndex, 1); - } - }); - persist(updated); + persist(removeCourse(schedule, yearNum, season, courseIndex)); } function handleDeleteYear(yearNum: number) { - const updated = produce(schedule, (draft) => { - const idx = yearNum - 1; - if (idx >= draft.years.length) return; - draft.years.splice(idx, 1); - draft.years.forEach((y, i) => { - y.year = i + 1; - }); - }); + const updated = deleteYear(schedule, yearNum); setExpandedYears((prev) => { const next = new Set(); for (let i = 1; i <= updated.years.length; i++) { @@ -277,24 +235,8 @@ export function BasePlanClient({ } function handleAddYear() { - const nextYear = schedule.years.length + 1; - const emptyTerm = (season: SeasonEnum): AuditTerm => ({ - season, - status: StatusEnum.CLASSES, - classes: [], - id: `${nextYear}-${season}`, - }); - const updated = produce(schedule, (draft) => { - draft.years.push({ - year: nextYear, - fall: emptyTerm(SeasonEnum.FL), - spring: emptyTerm(SeasonEnum.SP), - summer1: emptyTerm(SeasonEnum.S1), - summer2: emptyTerm(SeasonEnum.S2), - isSummerFull: false, - }); - }); - setExpandedYears((prev) => new Set([...prev, nextYear])); + const { schedule: updated, addedYear } = addYear(schedule); + setExpandedYears((prev) => new Set([...prev, addedYear])); persist(updated); } @@ -308,7 +250,7 @@ export function BasePlanClient({ >
-
+
+ + ); +} + +function CoursePickerItem({ + course, + selected, + onToggle, +}: { + course: AuditCourse; + selected: boolean; + onToggle: () => void; +}) { + const name = useCourseName(course.subject, Number(course.classId)); + return ( + + ); +} + +export function WhiteboardSection({ + section, + entry, + scheduleCourses, + onUpdate, + defaultOpen, + planCourses, +}: { + section: Section; + entry: WhiteboardEntry; + scheduleCourses: AuditCourse[]; + onUpdate: (entry: WhiteboardEntry) => void; + defaultOpen: boolean; + planCourses: Set; +}) { + const [opened, setOpened] = useState(defaultOpen); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const assignedSet = new Set(entry.courses); + const statusCfg = STATUS_CONFIG[entry.status]; + + const filteredCourses = scheduleCourses.filter((c) => { + const label = `${c.subject} ${c.classId} ${c.name}`.toLowerCase(); + return label.includes(search.toLowerCase()); + }); + + function cycleStatus(e: React.MouseEvent) { + e.stopPropagation(); + const idx = STATUS_CYCLE.indexOf(entry.status); + const next = STATUS_CYCLE[(idx + 1) % STATUS_CYCLE.length]; + onUpdate({ ...entry, status: next }); + } + + function toggleCourse(courseKey: string) { + const courses = assignedSet.has(courseKey) + ? entry.courses.filter((k) => k !== courseKey) + : [...entry.courses, courseKey]; + onUpdate({ ...entry, courses }); + } + + function removeCourse(courseKey: string) { + onUpdate({ + ...entry, + courses: entry.courses.filter((k) => k !== courseKey), + }); + } + + return ( +
setOpened(!opened)} + > +
+
+ + {section.title} +
+ {opened ? ( + + ) : ( + + )} +
+ + {opened && ( +
e.stopPropagation()} + > + {section.minRequirementCount < section.requirements.length && ( +

+ Complete {section.minRequirementCount} of the following: +

+ )} + {section.requirements.map((requirement, index) => ( + + ))} + +
+ + + + + +
+ setSearch(e.target.value)} + className="bg-neu1 border-neu3 focus:border-blue w-full rounded border px-2 py-1.5 text-sm outline-none" + /> +
+
+ {filteredCourses.length === 0 ? ( +

+ No courses found +

+ ) : ( + filteredCourses.map((c) => { + const key = `${c.subject} ${c.classId}`; + return ( + toggleCourse(key)} + /> + ); + }) + )} +
+
+
+
+ + {entry.courses.length > 0 && ( +
+ {entry.courses.map((key) => ( + removeCourse(key)} + /> + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/searchneu/components/graduate/sidebar/WhiteboardSidebar.tsx b/apps/searchneu/components/graduate/sidebar/WhiteboardSidebar.tsx index 6654eb3c..4c1e4d5b 100644 --- a/apps/searchneu/components/graduate/sidebar/WhiteboardSidebar.tsx +++ b/apps/searchneu/components/graduate/sidebar/WhiteboardSidebar.tsx @@ -1,543 +1,25 @@ "use client"; -import { - ChevronUp, - ChevronDown, - Plus, - X, - Check, - Minus, - Wand2, -} from "lucide-react"; +import { Wand2 } from "lucide-react"; import { useMemo, useState } from "react"; import { Audit, - AuditCourse, Whiteboard, WhiteboardEntry, - WhiteboardStatus, Section, - Requirement, - IAndCourse, - IOrCourse, - IXofManyCourse, - ICourseRange, - IRequiredCourse, Major, Minor, } from "@/lib/graduate/types"; import { creditsInAudit, UNDECIDED_CONCENTRATION, - courseToString, } from "@/lib/graduate/auditUtils"; -import { useCourseName } from "../CourseNameContext"; -import { Badge } from "@/components/ui/badge"; import { - Popover, - PopoverTrigger, - PopoverContent, -} from "@/components/ui/popover"; -import { collectScheduleCourses } from "@/lib/graduate/requirementUtils"; + collectScheduleCourses, + buildWhiteboardFromSchedule, +} from "@/lib/graduate/requirementUtils"; import { SidebarContainer } from "./SidebarContainer"; - -/** Recursively collects all COURSE-type requirement keys from a requirement tree. */ -function collectRequiredCourseKeys(req: Requirement): string[] { - if (req.type === "COURSE") { - return [`${req.subject} ${req.classId}`]; - } - if ( - req.type === "AND" || - req.type === "OR" || - req.type === "XOM" || - req.type === "SECTION" - ) { - const children = - req.type === "SECTION" - ? (req as Section).requirements - : (req as IAndCourse | IOrCourse | IXofManyCourse).courses; - return (children as Requirement[]).flatMap(collectRequiredCourseKeys); - } - return []; -} - -/** - * Builds a whiteboard by matching schedule courses against each section's - * requirements. Existing manually-added courses are preserved. - */ -function buildAutoFilledWhiteboard( - sections: Section[], - scheduleCourses: AuditCourse[], - current: Whiteboard, -): Whiteboard { - const scheduleCourseKeys = new Set( - scheduleCourses.map((c) => `${c.subject} ${c.classId}`), - ); - const updated: Whiteboard = { ...current }; - for (const section of sections) { - const sectionCourseKeys = new Set(); - for (const req of section.requirements) { - for (const key of collectRequiredCourseKeys(req)) { - sectionCourseKeys.add(key); - } - } - const matched = [...sectionCourseKeys].filter((k) => - scheduleCourseKeys.has(k), - ); - // Merge: keep existing courses, add newly matched ones - const existing = current[section.title]?.courses ?? []; - const merged = [...new Set([...existing, ...matched])]; - updated[section.title] = { - courses: merged, - status: - merged.length > 0 - ? (current[section.title]?.status ?? "in_progress") === "not_started" - ? "in_progress" - : (current[section.title]?.status ?? "in_progress") - : (current[section.title]?.status ?? "not_started"), - }; - } - return updated; -} - -const STATUS_CONFIG: Record< - WhiteboardStatus, - { label: string; border: string; bg: string; icon: React.ReactNode } -> = { - completed: { - label: "Completed", - border: "border-green", - bg: "bg-green", - icon: , - }, - in_progress: { - label: "In Progress", - border: "border-yellow-500", - bg: "bg-yellow-500", - icon: , - }, - not_started: { - label: "Not Started", - border: "border-neu5", - bg: "bg-neu5", - icon: , - }, -}; - -const STATUS_CYCLE: WhiteboardStatus[] = [ - "not_started", - "in_progress", - "completed", -]; - -// ── RequirementItem (recursive, from original Sidebar) ────────────────────── - -function CourseName({ - subject, - classId, - planCourses, - assignedCourses, - onAddCourse, -}: { - subject: string; - classId: number; - planCourses?: Set; - assignedCourses?: Set; - onAddCourse?: (courseKey: string) => void; -}) { - const name = useCourseName(subject, classId); - const key = `${subject} ${classId}`; - const inPlan = planCourses?.has(key) ?? false; - const isAssigned = assignedCourses?.has(key) ?? false; - - // Green when the course is both in plan AND assigned to this section - if (isAssigned) { - return ( - { - e.stopPropagation(); - onAddCourse?.(key); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - onAddCourse?.(key); - } - }} - className="hover:bg-green/10 cursor-pointer rounded px-0.5 transition-colors" - > - - {subject} {classId} - - {name && {name}} - - ); - } - - // Blue when the course is in the plan but not yet assigned to this section - if (inPlan && onAddCourse) { - return ( - { - e.stopPropagation(); - onAddCourse(key); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - onAddCourse(key); - } - }} - className="hover:bg-blue/10 cursor-pointer rounded px-0.5 transition-colors" - > - - {subject} {classId} - - {name && {name}} - - ); - } - - return ( - <> - - {subject} {classId} - - {name && {name}} - - ); -} - -interface RequirementItemProps { - req: Requirement; - planCourses?: Set; - assignedCourses?: Set; - onAddCourse?: (courseKey: string) => void; -} - -function RequirementItem({ - req, - planCourses, - assignedCourses, - onAddCourse, -}: RequirementItemProps) { - const cls = "pl-1 pt-1"; - const childProps = { planCourses, assignedCourses, onAddCourse }; - - if (req.type === "COURSE") { - const c = req as IRequiredCourse; - return ( -
-

- -

-
- ); - } - - if (req.type === "AND") { - const r = req as IAndCourse; - return ( -
-

- Complete all of the following: -

- {r.courses.map((c, i) => ( - - ))} -
- ); - } - - if (req.type === "OR") { - const r = req as IOrCourse; - return ( -
-

Complete 1 of the following:

- {r.courses.map((c, i) => ( - - ))} -
- ); - } - - if (req.type === "XOM") { - const r = req as IXofManyCourse; - return ( -
-

- Complete {r.numCreditsMin} credits from the following: -

- {r.courses.map((c, i) => ( - - ))} -
- ); - } - - if (req.type === "RANGE") { - const r = req as ICourseRange; - return ( -
-

- Complete any course in range {r.subject} - {r.idRangeStart} to {r.subject} - {r.idRangeEnd} - {r.exceptions.length > 0 && ( - <> except {r.exceptions.map(courseToString).join(", ")} - )} -

-
- ); - } - - if (req.type === "SECTION") { - const s = req as Section; - return ( -
-

{s.title}

- {s.requirements.map((r, i) => ( - - ))} -
- ); - } - - return null; -} - -// ── CourseBadge ────────────────────────────────────────────────────────────── - -function CourseBadge({ - courseKey, - onRemove, -}: { - courseKey: string; - onRemove: () => void; -}) { - const [subject, classId] = courseKey.split(" "); - const name = useCourseName(subject, Number(classId)); - return ( - - - {subject} {classId} - - {name && {name}} - - - ); -} - -// ── CoursePickerItem ───────────────────────────────────────────────────────── - -function CoursePickerItem({ - course, - selected, - onToggle, -}: { - course: AuditCourse; - selected: boolean; - onToggle: () => void; -}) { - const name = useCourseName(course.subject, Number(course.classId)); - return ( - - ); -} - -// ── WhiteboardSection ──────────────────────────────────────────────────────── - -function WhiteboardSection({ - section, - entry, - scheduleCourses, - onUpdate, - defaultOpen, - planCourses, -}: { - section: Section; - entry: WhiteboardEntry; - scheduleCourses: AuditCourse[]; - onUpdate: (entry: WhiteboardEntry) => void; - defaultOpen: boolean; - planCourses: Set; -}) { - const [opened, setOpened] = useState(defaultOpen); - const [pickerOpen, setPickerOpen] = useState(false); - const [search, setSearch] = useState(""); - const assignedSet = new Set(entry.courses); - const statusCfg = STATUS_CONFIG[entry.status]; - - const filteredCourses = scheduleCourses.filter((c) => { - const label = `${c.subject} ${c.classId} ${c.name}`.toLowerCase(); - return label.includes(search.toLowerCase()); - }); - - function cycleStatus(e: React.MouseEvent) { - e.stopPropagation(); - const idx = STATUS_CYCLE.indexOf(entry.status); - const next = STATUS_CYCLE[(idx + 1) % STATUS_CYCLE.length]; - onUpdate({ ...entry, status: next }); - } - - function toggleCourse(courseKey: string) { - const courses = assignedSet.has(courseKey) - ? entry.courses.filter((k) => k !== courseKey) - : [...entry.courses, courseKey]; - onUpdate({ ...entry, courses }); - } - - function removeCourse(courseKey: string) { - onUpdate({ - ...entry, - courses: entry.courses.filter((k) => k !== courseKey), - }); - } - - return ( -
setOpened(!opened)} - > - {/* Section header — matches original Sidebar styling */} -
-
- - {section.title} -
- {opened ? ( - - ) : ( - - )} -
- - {opened && ( -
e.stopPropagation()} - > - {/* Requirement info from the major */} - {section.minRequirementCount < section.requirements.length && ( -

- Complete {section.minRequirementCount} of the following: -

- )} - {section.requirements.map((requirement, index) => ( - - ))} - - {/* Add courses button + popover (pinned at top for stable position) */} -
- - - - - -
- setSearch(e.target.value)} - className="bg-neu1 border-neu3 focus:border-blue w-full rounded border px-2 py-1.5 text-sm outline-none" - /> -
-
- {filteredCourses.length === 0 ? ( -

- No courses found -

- ) : ( - filteredCourses.map((c) => { - const key = `${c.subject} ${c.classId}`; - return ( - toggleCourse(key)} - /> - ); - }) - )} -
-
-
-
- - {/* Assigned courses */} - {entry.courses.length > 0 && ( -
- {entry.courses.map((key) => ( - removeCourse(key)} - /> - ))} -
- )} -
- )} -
- ); -} - -// ── Main WhiteboardSidebar ─────────────────────────────────────────────────── +import { WhiteboardSection } from "./WhiteboardSection"; export function WhiteboardSidebar({ schedule, @@ -565,7 +47,6 @@ export function WhiteboardSidebar({ const creditsToTake = currentMajor?.totalCreditsRequired ?? 0; const creditsTaken = creditsInAudit(schedule); const scheduleCourses = collectScheduleCourses(schedule); - // Set of all courses in the user's plan/schedule const planCourses = useMemo( () => new Set(scheduleCourses.map((c) => `${c.subject} ${c.classId}`)), [scheduleCourses], @@ -610,12 +91,9 @@ export function WhiteboardSidebar({ ...(currentMajor?.requirementSections ?? []), ...(currentMinor?.requirementSections ?? []), ]; - const updated = buildAutoFilledWhiteboard( - allSections, - scheduleCourses, - whiteboard, + onWhiteboardChange( + buildWhiteboardFromSchedule(allSections, schedule, whiteboard), ); - onWhiteboardChange(updated); } return ( diff --git a/apps/searchneu/components/graduate/validationClient.tsx b/apps/searchneu/components/graduate/validationClient.tsx deleted file mode 100644 index 2e6d2bdd..00000000 --- a/apps/searchneu/components/graduate/validationClient.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, useCallback } from "react"; -import { Button } from "../ui/button"; -import { - WorkerMessageType, - WorkerMessage, - WorkerPostInfo, -} from "../../lib/graduate/validation-worker/worker"; -import { Major } from "@/lib/graduate/types"; - -type ValidationState = - | { status: "idle" } - | { status: "loading" } - | { status: "success"; result: unknown } - | { - status: "error"; - error: { name: string; message: string; field?: string }; - }; - -function ValidationClient() { - const workerRef = useRef(null); - const [validationState, setValidationState] = useState({ - status: "idle", - }); - const requestNumberRef = useRef(0); - - useEffect(() => { - const worker = new Worker( - new URL( - "../../lib/graduate/validation-worker/worker.ts", - import.meta.url, - ), - ); - workerRef.current = worker; - - // Handle messages from the worker - worker.onmessage = (event: MessageEvent) => { - const message = event.data; - - switch (message.type) { - case WorkerMessageType.Loaded: - console.log("Worker loaded successfully"); - break; - - case WorkerMessageType.ValidationResult: - console.log("Validation result:", message.result); - setValidationState({ status: "success", result: message.result }); - break; - - case WorkerMessageType.ValidationError: - console.error("Validation error:", message.error); - setValidationState({ - status: "error", - error: { - name: message.error.name, - message: message.error.message, - field: message.error.field, - }, - }); - break; - } - }; - - // Handle uncaught errors in the worker - worker.onerror = (error: ErrorEvent) => { - console.error("Worker error:", error); - setValidationState({ - status: "error", - error: { - name: "WorkerError", - message: - error.message || "An unexpected error occurred in the worker", - }, - }); - }; - - // Cleanup on unmount - return () => { - worker.terminate(); - }; - }, []); - - const runValidation = useCallback( - (data: Omit) => { - if (!workerRef.current) { - setValidationState({ - status: "error", - error: { - name: "WorkerNotReady", - message: "Worker is not initialized", - }, - }); - return; - } - - requestNumberRef.current += 1; - setValidationState({ status: "loading" }); - - const message: WorkerPostInfo = { - ...data, - requestNumber: requestNumberRef.current, - }; - - workerRef.current.postMessage(message); - }, - [], - ); - - const major2Example = { - name: "Test Major", - requirementSections: [], - totalCreditsRequired: 0, - yearVersion: 0, - }; - const testGoodValidation = () => { - runValidation({ - major: major2Example as Major, - taken: [], - }); - }; - - return ( -
-

ValidationClient

- - - - {validationState.status === "loading" && ( -

Validating...

- )} - - {validationState.status === "error" && ( -
-

- {validationState.error.name} -

-

- {validationState.error.message} -

- {validationState.error.field && ( -

- Field: {validationState.error.field} -

- )} -
- )} - - {validationState.status === "success" && ( -
-

Validation Successful

-
-            {JSON.stringify(validationState.result, null, 2)}
-          
-
- )} -
- ); -} - -export { ValidationClient }; diff --git a/apps/searchneu/components/navigation/NavBar.tsx b/apps/searchneu/components/navigation/NavBar.tsx index ee869d6b..493655ed 100644 --- a/apps/searchneu/components/navigation/NavBar.tsx +++ b/apps/searchneu/components/navigation/NavBar.tsx @@ -50,7 +50,7 @@ export function NavBar({ {graduateFlag && ( diff --git a/apps/searchneu/lib/dal/audits.ts b/apps/searchneu/lib/dal/audits.ts index d6bf43ac..8b7a7a16 100644 --- a/apps/searchneu/lib/dal/audits.ts +++ b/apps/searchneu/lib/dal/audits.ts @@ -12,71 +12,8 @@ import { } from "../controllers/majors"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; -import { Audit, Requirement, Section } from "../graduate/types"; - -/** - * Recursively collects all IRequiredCourse entries from a requirement tree, - * returning them as "SUBJECT CLASSID" strings. - */ -function collectRequiredCourseKeys(req: Requirement): string[] { - if (req.type === "COURSE") { - return [`${req.subject} ${req.classId}`]; - } - if ( - req.type === "AND" || - req.type === "OR" || - req.type === "XOM" || - req.type === "SECTION" - ) { - const children = - req.type === "SECTION" - ? (req as Section).requirements - : (req as { courses: Requirement[] }).courses; - return children.flatMap(collectRequiredCourseKeys); - } - return []; -} - -/** - * Builds a whiteboard pre-populated with courses from the schedule that match - * requirements in each section. Matching is exact: subject + classId. - */ -function autoPopulateWhiteboard( - sections: Section[], - schedule: Audit, -): Record { - // Collect all "SUBJECT CLASSID" keys from the schedule - const scheduleCourseKeys = new Set(); - for (const year of schedule.years ?? []) { - for (const term of [year.fall, year.spring, year.summer1, year.summer2]) { - for (const c of term.classes) { - scheduleCourseKeys.add(`${c.subject} ${c.classId}`); - } - } - } - - const whiteboard: Record = {}; - for (const section of sections) { - // Collect every required course key mentioned in this section - const sectionCourseKeys = new Set(); - for (const req of section.requirements) { - for (const key of collectRequiredCourseKeys(req)) { - sectionCourseKeys.add(key); - } - } - - // Find which of those appear in the schedule - const matched = [...sectionCourseKeys].filter((k) => - scheduleCourseKeys.has(k), - ); - - whiteboard[section.title] = { - courses: matched, - status: matched.length > 0 ? "in_progress" : "not_started", - }; - } - return whiteboard; -} +import { Whiteboard } from "../graduate/types"; +import { buildWhiteboardFromSchedule } from "../graduate/requirementUtils"; /** * Verifies the current user from their JWT token and returns their user data. @@ -156,7 +93,7 @@ export async function createAuditPlan( ) { const { name, schedule, majors, minors, concentration, catalogYear } = createAuditPlanInput; - const whiteboard: Record = {}; + let whiteboard: Whiteboard = {}; if (majors) { const majorName = await getByMajorAndYear(majors, catalogYear ?? 0); @@ -167,11 +104,10 @@ export async function createAuditPlan( ); return null; } else { - const populated = autoPopulateWhiteboard( + whiteboard = buildWhiteboardFromSchedule( majorName.requirementSections, - schedule as Audit, + schedule, ); - Object.assign(whiteboard, populated); } } diff --git a/apps/searchneu/lib/graduate/api-dtos.test.ts b/apps/searchneu/lib/graduate/api-dtos.test.ts new file mode 100644 index 00000000..ffc6797e --- /dev/null +++ b/apps/searchneu/lib/graduate/api-dtos.test.ts @@ -0,0 +1,181 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CreateAuditPlanDto, UpdateAuditPlanDto } from "./api-dtos"; +import { SeasonEnum, StatusEnum } from "./types"; + +const validSchedule = { + years: [ + { + year: 1, + fall: { + season: SeasonEnum.FL, + status: StatusEnum.CLASSES, + classes: [ + { + name: "Fundamentals of CS 1", + classId: "2500", + subject: "CS", + numCreditsMin: 4, + numCreditsMax: 4, + id: null, + nupaths: ["FQ"], + }, + ], + id: null, + }, + spring: { + season: SeasonEnum.SP, + status: StatusEnum.CLASSES, + classes: [], + id: null, + }, + summer1: { + season: SeasonEnum.S1, + status: StatusEnum.INACTIVE, + classes: [], + id: null, + }, + summer2: { + season: SeasonEnum.S2, + status: StatusEnum.INACTIVE, + classes: [], + id: null, + }, + isSummerFull: false, + }, + ], +}; + +test("CreateAuditPlanDto: accepts a valid payload (matches what NewPlanModal POSTs)", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "My Plan", + schedule: validSchedule, + majors: ["Computer Science, BSCS"], + minors: undefined, + catalogYear: 2026, + concentration: undefined, + }); + assert.equal(result.success, true); +}); + +test("CreateAuditPlanDto: rejects an unknown extra field at the top level", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "X", + schedule: validSchedule, + bogus: 1, + }); + assert.equal(result.success, false); +}); + +test("CreateAuditPlanDto: rejects schedule with malformed term (bad season)", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "X", + schedule: { + years: [ + { + year: 1, + fall: { + season: "WINTER", + status: StatusEnum.CLASSES, + classes: [], + id: null, + }, + spring: validSchedule.years[0].spring, + summer1: validSchedule.years[0].summer1, + summer2: validSchedule.years[0].summer2, + isSummerFull: false, + }, + ], + }, + }); + assert.equal(result.success, false); +}); + +test("CreateAuditPlanDto: rejects schedule with malformed course (missing classId)", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "X", + schedule: { + years: [ + { + ...validSchedule.years[0], + fall: { + ...validSchedule.years[0].fall, + classes: [ + { + name: "x", + subject: "CS", + numCreditsMin: 4, + numCreditsMax: 4, + id: null, + }, + ], + }, + }, + ], + }, + }); + assert.equal(result.success, false); +}); + +test("CreateAuditPlanDto: rejects schedule with non-object", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "X", + schedule: "not a schedule", + }); + assert.equal(result.success, false); +}); + +test("CreateAuditPlanDto: catalogYear out of range is rejected", () => { + const result = CreateAuditPlanDto.safeParse({ + name: "X", + schedule: validSchedule, + catalogYear: 1500, + }); + assert.equal(result.success, false); +}); + +test("UpdateAuditPlanDto: accepts schedule-only update (PlanClient PATCH)", () => { + const result = UpdateAuditPlanDto.safeParse({ schedule: validSchedule }); + assert.equal(result.success, true); +}); + +test("UpdateAuditPlanDto: accepts whiteboard-only update (PlanClient whiteboard PATCH)", () => { + const result = UpdateAuditPlanDto.safeParse({ + whiteboard: { + "Foundation Courses": { + courses: ["CS 2500", "CS 2510"], + status: "in_progress", + }, + }, + }); + assert.equal(result.success, true); +}); + +test("UpdateAuditPlanDto: rejects whiteboard with invalid status", () => { + const result = UpdateAuditPlanDto.safeParse({ + whiteboard: { + Section: { courses: [], status: "wat" }, + }, + }); + assert.equal(result.success, false); +}); + +test("UpdateAuditPlanDto: explicit null wipes are accepted (majors/minors/catalogYear/concentration)", () => { + const result = UpdateAuditPlanDto.safeParse({ + majors: null, + minors: null, + catalogYear: null, + concentration: null, + }); + assert.equal(result.success, true); +}); + +test("UpdateAuditPlanDto: empty object is valid (no-op patch)", () => { + const result = UpdateAuditPlanDto.safeParse({}); + assert.equal(result.success, true); +}); + +test("UpdateAuditPlanDto: rejects unknown extra field", () => { + const result = UpdateAuditPlanDto.safeParse({ name: "x", oops: 1 }); + assert.equal(result.success, false); +}); diff --git a/apps/searchneu/lib/graduate/api-dtos.ts b/apps/searchneu/lib/graduate/api-dtos.ts index bce15f3c..461609a1 100644 --- a/apps/searchneu/lib/graduate/api-dtos.ts +++ b/apps/searchneu/lib/graduate/api-dtos.ts @@ -1,8 +1,55 @@ import * as z from "zod"; +import type { Requisite } from "@sneu/scraper/types"; +import { NUPathEnum, SeasonEnum, StatusEnum } from "./types"; const MIN_YEAR = 1898; const MAX_YEAR = 3000; +// Mirrors lib/graduate/types.Audit. prereqs/coreqs originate in @sneu/scraper +// and are passed through as JSON without re-validation here. +const AuditCourseSchema = z.object({ + name: z.string(), + classId: z.string(), + subject: z.string(), + prereqs: z.custom().optional(), + coreqs: z.custom().optional(), + nupaths: z + .array(z.custom((v) => typeof v === "string")) + .optional(), + numCreditsMin: z.number(), + numCreditsMax: z.number(), + id: z.string().nullable(), + generic: z.boolean().optional(), +}); + +const AuditTermSchema = z.object({ + season: z.enum(SeasonEnum), + status: z.enum(StatusEnum), + classes: z.array(AuditCourseSchema), + id: z.string().nullable(), +}); + +const AuditYearSchema = z.object({ + year: z.number(), + fall: AuditTermSchema, + spring: AuditTermSchema, + summer1: AuditTermSchema, + summer2: AuditTermSchema, + isSummerFull: z.boolean(), +}); + +const AuditScheduleSchema = z.object({ + years: z.array(AuditYearSchema), +}); + +const WhiteboardSchema = z.record( + z.string(), + z.object({ + courses: z.string().array(), + status: z.enum(["not_started", "in_progress", "completed"]), + }), +); + export const CreateAuditPlanDtoWithoutSchedule = z.strictObject({ name: z.string(), majors: z.string().array().optional(), @@ -13,12 +60,12 @@ export const CreateAuditPlanDtoWithoutSchedule = z.strictObject({ }); export const CreateAuditPlanDto = CreateAuditPlanDtoWithoutSchedule.extend({ - schedule: z.any(), + schedule: AuditScheduleSchema, }); export const UpdateAuditPlanDto = z.strictObject({ name: z.string().optional(), - schedule: z.any().optional(), + schedule: AuditScheduleSchema.optional(), majors: z.string().array().nullable().optional(), minors: z.string().array().nullable().optional(), concentration: z.string().nullable().optional(), @@ -29,15 +76,7 @@ export const UpdateAuditPlanDto = z.strictObject({ .max(MAX_YEAR) .nullable() .optional(), - whiteboard: z - .record( - z.string(), - z.object({ - courses: z.string().array(), - status: z.enum(["not_started", "in_progress", "completed"]), - }), - ) - .optional(), + whiteboard: WhiteboardSchema.optional(), }); export type CreateAuditPlanInput = z.infer; diff --git a/apps/searchneu/lib/graduate/planUtils.ts b/apps/searchneu/lib/graduate/planUtils.ts index ca3af914..371f1ece 100644 --- a/apps/searchneu/lib/graduate/planUtils.ts +++ b/apps/searchneu/lib/graduate/planUtils.ts @@ -3,6 +3,8 @@ import { pointerWithin, rectIntersection, } from "@dnd-kit/core"; +import { produce } from "immer"; +import { SeasonEnum, StatusEnum } from "./types"; import type { Audit, AuditTerm } from "./types"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -77,6 +79,66 @@ export function flatTerms(schedule: Audit): AuditTerm[] { return terms; } +// ── Schedule Mutations ─────────────────────────────────────────────────────── + +/** Append a new empty year to the schedule. Returns the new schedule and the year number. */ +export function addYear(schedule: Audit): { + schedule: Audit; + addedYear: number; +} { + const addedYear = schedule.years.length + 1; + const emptyTerm = (season: SeasonEnum): AuditTerm => ({ + season, + status: StatusEnum.CLASSES, + classes: [], + id: `${addedYear}-${season}`, + }); + const next = produce(schedule, (draft) => { + draft.years.push({ + year: addedYear, + fall: emptyTerm(SeasonEnum.FL), + spring: emptyTerm(SeasonEnum.SP), + summer1: emptyTerm(SeasonEnum.S1), + summer2: emptyTerm(SeasonEnum.S2), + isSummerFull: false, + }); + }); + return { schedule: next, addedYear }; +} + +/** Delete the given year (1-indexed) and renumber remaining years. */ +export function deleteYear(schedule: Audit, yearNum: number): Audit { + return produce(schedule, (draft) => { + const idx = yearNum - 1; + if (idx >= draft.years.length) return; + draft.years.splice(idx, 1); + draft.years.forEach((y, i) => { + y.year = i + 1; + }); + }); +} + +/** Remove a single course at (year, season, courseIndex). */ +export function removeCourse( + schedule: Audit, + yearNum: number, + season: SeasonEnum, + courseIndex: number, +): Audit { + return produce(schedule, (draft) => { + const year = draft.years.find((y) => y.year === yearNum); + if (!year) return; + const termMap: Record = { + [SeasonEnum.FL]: year.fall, + [SeasonEnum.SP]: year.spring, + [SeasonEnum.S1]: year.summer1, + [SeasonEnum.S2]: year.summer2, + }; + const term = termMap[season]; + if (term) term.classes.splice(courseIndex, 1); + }); +} + // ── Collision Detection ────────────────────────────────────────────────────── export const collisionAlgorithm: CollisionDetection = (args) => { diff --git a/apps/searchneu/lib/graduate/requirementUtils.test.ts b/apps/searchneu/lib/graduate/requirementUtils.test.ts new file mode 100644 index 00000000..cc757cae --- /dev/null +++ b/apps/searchneu/lib/graduate/requirementUtils.test.ts @@ -0,0 +1,143 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildWhiteboardFromSchedule, + pruneWhiteboard, + collectRequiredCourseKeys, +} from "./requirementUtils"; +import { Audit, Section, SeasonEnum, StatusEnum, Whiteboard } from "./types"; + +const term = ( + season: SeasonEnum, + classes: { subject: string; classId: string }[] = [], +) => ({ + season, + status: StatusEnum.CLASSES, + id: null, + classes: classes.map((c) => ({ + name: "", + subject: c.subject, + classId: c.classId, + numCreditsMin: 4, + numCreditsMax: 4, + id: null, + })), +}); + +const scheduleWith = ( + courses: { subject: string; classId: string }[], +): Audit => ({ + years: [ + { + year: 1, + fall: term(SeasonEnum.FL, courses), + spring: term(SeasonEnum.SP), + summer1: term(SeasonEnum.S1), + summer2: term(SeasonEnum.S2), + isSummerFull: false, + }, + ], +}); + +const sectionWithCourses = ( + title: string, + courses: { subject: string; classId: number }[], +): Section => ({ + type: "SECTION", + title, + minRequirementCount: courses.length, + requirements: courses.map((c) => ({ + type: "COURSE", + subject: c.subject, + classId: c.classId, + })), +}); + +test("collectRequiredCourseKeys: flattens AND/OR/XOM/SECTION trees", () => { + const keys = collectRequiredCourseKeys({ + type: "AND", + courses: [ + { type: "COURSE", subject: "CS", classId: 2500 }, + { + type: "OR", + courses: [ + { type: "COURSE", subject: "CS", classId: 2510 }, + { + type: "SECTION", + title: "nested", + minRequirementCount: 1, + requirements: [{ type: "COURSE", subject: "MATH", classId: 1341 }], + }, + ], + }, + ], + }); + assert.deepEqual(keys.sort(), ["CS 2500", "CS 2510", "MATH 1341"].sort()); +}); + +test("buildWhiteboardFromSchedule (fresh): matches set in_progress, unmatched set not_started", () => { + const sections = [ + sectionWithCourses("Foundations", [ + { subject: "CS", classId: 2500 }, + { subject: "CS", classId: 2510 }, + ]), + sectionWithCourses("Math", [{ subject: "MATH", classId: 1341 }]), + ]; + const schedule = scheduleWith([{ subject: "CS", classId: "2500" }]); + const wb = buildWhiteboardFromSchedule(sections, schedule); + assert.deepEqual(wb.Foundations, { + courses: ["CS 2500"], + status: "in_progress", + }); + assert.deepEqual(wb.Math, { courses: [], status: "not_started" }); +}); + +test("buildWhiteboardFromSchedule (merge): preserves existing courses + completed status", () => { + const sections = [ + sectionWithCourses("Foundations", [ + { subject: "CS", classId: 2500 }, + { subject: "CS", classId: 2510 }, + ]), + ]; + const schedule = scheduleWith([{ subject: "CS", classId: "2500" }]); + const current: Whiteboard = { + Foundations: { courses: ["CS 9999"], status: "completed" }, + }; + const wb = buildWhiteboardFromSchedule(sections, schedule, current); + // Manual courses kept, matched added, status preserved as completed + assert.deepEqual(wb.Foundations, { + courses: ["CS 9999", "CS 2500"], + status: "completed", + }); +}); + +test("buildWhiteboardFromSchedule (merge): not_started auto-upgrades to in_progress when match appears", () => { + const sections = [ + sectionWithCourses("S", [{ subject: "CS", classId: 2500 }]), + ]; + const schedule = scheduleWith([{ subject: "CS", classId: "2500" }]); + const current: Whiteboard = { + S: { courses: [], status: "not_started" }, + }; + const wb = buildWhiteboardFromSchedule(sections, schedule, current); + assert.equal(wb.S.status, "in_progress"); +}); + +test("pruneWhiteboard: removes whiteboard courses no longer in schedule", () => { + const schedule = scheduleWith([{ subject: "CS", classId: "2500" }]); + const wb: Whiteboard = { + Foundations: { courses: ["CS 2500", "CS 9999"], status: "in_progress" }, + }; + const pruned = pruneWhiteboard(schedule, wb); + assert.deepEqual(pruned, { + Foundations: { courses: ["CS 2500"], status: "in_progress" }, + }); +}); + +test("pruneWhiteboard: returns null when nothing was removed (caller can skip persist)", () => { + const schedule = scheduleWith([{ subject: "CS", classId: "2500" }]); + const wb: Whiteboard = { + Foundations: { courses: ["CS 2500"], status: "in_progress" }, + }; + assert.equal(pruneWhiteboard(schedule, wb), null); +}); diff --git a/apps/searchneu/lib/graduate/requirementUtils.ts b/apps/searchneu/lib/graduate/requirementUtils.ts index a78697d2..a306024c 100644 --- a/apps/searchneu/lib/graduate/requirementUtils.ts +++ b/apps/searchneu/lib/graduate/requirementUtils.ts @@ -8,7 +8,8 @@ import { IXofManyCourse, ICourseRange, IRequiredCourse, - NUPathEnum, + Whiteboard, + WhiteboardStatus, } from "./types"; /** Collect all "SUBJECT CLASSID" keys present in a schedule. */ @@ -147,6 +148,82 @@ export function isSectionComplete( return fulfilled >= section.minRequirementCount; } +/** Recursively collect all "SUBJECT CLASSID" keys from required courses in a requirement tree. */ +export function collectRequiredCourseKeys(req: Requirement): string[] { + if (req.type === "COURSE") { + return [`${req.subject} ${req.classId}`]; + } + if ( + req.type === "AND" || + req.type === "OR" || + req.type === "XOM" || + req.type === "SECTION" + ) { + const children = + req.type === "SECTION" + ? (req as Section).requirements + : (req as IAndCourse | IOrCourse | IXofManyCourse).courses; + return (children as Requirement[]).flatMap(collectRequiredCourseKeys); + } + return []; +} + +/** + * Build a whiteboard by matching schedule courses against each section's + * requirements. If `current` is provided, manual entries are preserved and + * "not_started" auto-upgrades to "in_progress" once a match exists. + */ +export function buildWhiteboardFromSchedule( + sections: Section[], + schedule: Audit, + current: Whiteboard = {}, +): Whiteboard { + const scheduleKeys = collectScheduleCourseKeys(schedule); + const updated: Whiteboard = { ...current }; + + for (const section of sections) { + const sectionKeys = new Set(); + for (const req of section.requirements) { + for (const key of collectRequiredCourseKeys(req)) { + sectionKeys.add(key); + } + } + const matched = [...sectionKeys].filter((k) => scheduleKeys.has(k)); + const existingCourses = current[section.title]?.courses ?? []; + const merged = [...new Set([...existingCourses, ...matched])]; + const existingStatus = current[section.title]?.status; + + let status: WhiteboardStatus; + if (merged.length > 0) { + status = + (existingStatus ?? "not_started") === "not_started" + ? "in_progress" + : (existingStatus ?? "in_progress"); + } else { + status = existingStatus ?? "not_started"; + } + + updated[section.title] = { courses: merged, status }; + } + return updated; +} + +/** Remove whiteboard course entries that no longer exist in the schedule. */ +export function pruneWhiteboard( + schedule: Audit, + wb: Whiteboard, +): Whiteboard | null { + const valid = collectScheduleCourseKeys(schedule); + let changed = false; + const pruned: Whiteboard = {}; + for (const [section, entry] of Object.entries(wb)) { + const filtered = entry.courses.filter((k) => valid.has(k)); + if (filtered.length !== entry.courses.length) changed = true; + pruned[section] = { ...entry, courses: filtered }; + } + return changed ? pruned : null; +} + /** Collect all NUPath short codes fulfilled by courses in the schedule. */ export function collectFulfilledNupaths(schedule: Audit): Set { const fulfilled = new Set(); diff --git a/apps/searchneu/lib/graduate/validation-worker/course-utils.ts b/apps/searchneu/lib/graduate/validation-worker/course-utils.ts deleted file mode 100644 index ee1ab653..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/course-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IRequiredCourse } from "../types"; - -export const courseToString = (c: { - subject: string; - classId: number | string; -}) => `${c.subject.toUpperCase()}${c.classId}`; - -type CourseIdentifier = { classId: number | string; subject: string }; -export const courseEq = (c1: CourseIdentifier, c2: CourseIdentifier) => - String(c1.classId) === String(c2.classId) && c1.subject === c2.subject; - -export const coursesToString = (c: IRequiredCourse[]) => - c.map(courseToString).join(","); - -export const assertUnreachable = (): never => { - throw new Error("This code is unreachable"); -}; diff --git a/apps/searchneu/lib/graduate/validation-worker/major-validation.test.ts b/apps/searchneu/lib/graduate/validation-worker/major-validation.test.ts deleted file mode 100644 index b9ec984e..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/major-validation.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { - AndErrorNoSolution, - AndErrorUnsatChild, - AndErrorUnsatChildAndNoSolution, - ChildError, - CourseError, - getConcentrationsRequirement, - MajorValidationError, - MajorValidationResult, - SectionError, - TotalCreditsRequirementError, - validateMajor, - validateRequirement, -} from "./major-validation"; -import { type Major, Section, Err, Ok, ResultType } from "../types"; -import { - course, - convert, - or, - and, - range, - xom, - section, - solution, - concentrations, - makeTracker, - convertToMajor, -} from "./test-utils"; -import bscs from "./mock-majors/bscs.json"; -import { test, describe } from "node:test"; -import assert from "node:assert"; -import { - cs2810, - cs3950, - cs4805, - cs4810, - cs4830, - cs4820, - cs4950, - ds3000, - cy4770, - cs1200, - cs1210, - cs1800, - cs1802, - cs2500, - cs2501, - cs2510, - cs2511, - cs2800, - cs2801, - cs3000, - cs3500, - cs3650, - cs3700, - cs3800, - cs4400, - cs4500, - cs4501, - thtr1170, - cs4410, - cs2550, - ds4300, - cs4300, - math1341, - math1342, - math2331, - math3081, - phil1145, - eece2160, - chem1211, - chem1212, - chem1213, - chem1214, - chem1215, - chem1216, - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - engw1111, - engw3302, - cs1990, - hist1130, - math2321, - honr1310, - math3527, - artg1250, - artg2400, -} from "./mock-courses"; - -const child = (error: MajorValidationError, index: number): ChildError => { - return { - childIndex: index, - ...error, - }; -}; - -describe("validateRequirement suite", () => { - // (CS2810 or CS2800) and (CS2810 or DS3000) - const cs2810orcs2800 = or(cs2810, cs2800); - const cs2810ords3000 = or(cs2810, ds3000); - const cs2000tocs3000 = range(8, "CS", 2000, 3000, []); - const rangeException = range(4, "CS", 2000, 4000, [cs2810]); - const xom8credits = xom(8, [cs2800, cs2810, ds3000]); - const xom4credits = xom(4, [cs2500, cs2501]); - const xom4creditsWrongOrder = xom(4, [cs2501, cs2500]); - const input = and(cs2810orcs2800, cs2810ords3000); - const tracker = makeTracker(cs2800, cs2810, ds3000, cs3500); - const xomTracker = makeTracker(cs2500, cs2501); - test("or 1", () => { - assert.deepEqual( - validateRequirement(cs2810orcs2800, tracker), - Ok([solution("CS2810"), solution("CS2800")]), - ); - }); - test("or 2", () => { - assert.deepEqual( - validateRequirement(cs2810ords3000, tracker), - Ok([solution("CS2810"), solution("DS3000")]), - ); - }); - - test("and of ors", () => { - assert.deepStrictEqual( - validateRequirement(input, tracker), - Ok([ - // (CS2810 or CS2800) and (CS2810 or DS3000) - solution("CS2810", "DS3000"), - solution("CS2800", "CS2810"), - solution("CS2800", "DS3000"), - ]), - ); - }); - test("and no solutions", () => { - assert.deepStrictEqual( - validateRequirement( - and(and(cs2810, cs2800), and(cs2810, cs2800)), - tracker, - ), - Err(AndErrorNoSolution(1)), - ); - }); - test("range of courses", () => { - assert.deepStrictEqual( - validateRequirement(cs2000tocs3000, tracker), - Ok([solution(cs2800), solution("CS2800", "CS2810"), solution(cs2810)]), - ); - }); - test("range of courses with exception", () => { - assert.deepStrictEqual( - validateRequirement(rangeException, tracker), - Ok([solution("CS2800"), solution(cs2800, cs3500), solution("CS3500")]), - ); - }); - test("XOM requirement", () => { - assert.deepStrictEqual( - validateRequirement(xom8credits, tracker), - Ok([ - solution("CS2800", "CS2810"), - solution("CS2800", "DS3000"), - solution("CS2810", "DS3000"), - ]), - ); - }); - test("XOM requirement without duplicates", () => { - assert.deepStrictEqual( - validateRequirement(xom4credits, xomTracker), - Ok([solution("CS2500")]), - ); - }); - test("XOM requirement without duplicates in wrong order", () => { - assert.deepStrictEqual( - validateRequirement(xom4creditsWrongOrder, xomTracker), - Ok([solution(cs2501, cs2500), solution("CS2500")]), - ); - }); - const foundations = section("Foundations", 2, [ - xom(8, [or(and(cs2800, cs2801), cs4820), or(cs4805, cs4810)]), - xom(8, [ - cs4805, - cs4810, - cs4820, - cs4830, - and(cs3950, cs4950, cs4950), - cy4770, - ]), - ]); - const foundationsCourses1 = makeTracker( - cs2801, - cs2800, - cs4810, - cs4805, - cs3950, - cs4950, - cs4950, - ); - test("integration", () => { - assert.deepStrictEqual( - validateRequirement(foundations, foundationsCourses1), - Ok([ - solution(cs2800, cs2801, cs4810, cs4805, cs3950, cs4950, cs4950), - solution(cs2800, cs2801, cs4805, cs4810, cs3950, cs4950, cs4950), - ]), - ); - }); - - test("section", () => { - const tracker = makeTracker(cs2800, cs2810); - const r = section("s1", 2, [cs2800, cs2810, cs3500]); - assert.deepStrictEqual( - validateRequirement(r, tracker), - Ok([solution(cs2800, cs2810)]), - ); - }); - test("range allows duplicates", () => { - const tracker = makeTracker(cs2800, cs2800); - const r = range(8, "CS", 2000, 3000, []); - assert.deepStrictEqual( - validateRequirement(r, tracker), - Ok([solution(cs2800), solution(cs2800, cs2800), solution(cs2800)]), - ); - }); - test("concentrations", () => { - const twoConcentrations = concentrations( - 2, - section("1", 1, [cs2800]), - section("2", 1, [cs2810]), - section("3", 1, [ds3000]), - ); - assert.deepStrictEqual( - validateRequirement( - getConcentrationsRequirement([1, "3"], twoConcentrations)[0], - tracker, - ), - Ok([solution(cs2810, ds3000)]), - ); - }); -}); - -describe("integration suite", () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bscs2 = convertToMajor(bscs as any); - const taken = [ - cs1200, - cs1210, - cs1800, - cs1802, - cs2500, - cs2501, - cs2510, - cs2511, - cs2800, - cs2801, - cs3000, - cs3500, - cs3650, - cs3700, - cs3800, - cs4400, - cs4500, - cs4501, - cs4410, - cs2550, - ds4300, - cs1990, - cs1990, - thtr1170, - math1341, - math1342, - math2331, - math3081, - phil1145, - eece2160, - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - engw1111, - engw3302, - hist1130, - math2321, - honr1310, - math3527, - artg1250, - artg2400, - ]; - const tracker = makeTracker(...taken); - const scheduleCourses = taken.map(convert); - for (const r of bscs2.requirementSections) { - test(r.title, () => { - validateRequirement(r, tracker); - }); - } - test("alex's full major", () => { - const actual = validateMajor(bscs2, scheduleCourses); - const expected = [ - solution( - cs1200, - cs1210, - cs1800, - cs1802, - cs2500, - cs2501, - cs2510, - cs2511, - cs2800, - cs2801, - cs3000, - cs3500, - cs3650, - cs3700, - cs3800, - cs4400, - cs4500, - cs4501, - thtr1170, - cs4410, - cs2550, - ds4300, - math1341, - math1342, - math2331, - math3081, - phil1145, - eece2160, - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - engw1111, - engw3302, - ), - ]; - assert.deepStrictEqual(actual, Ok(expected)); - assert.ok(taken.length > expected.length); - }); - test("cindy's full major", () => { - const taken = [ - cs1200, - cs1800, - cs1802, - cs2500, - cs2501, - engw1111, - cs2510, - cs2511, - cs2810, - cs2800, - cs2801, - cs3500, - math3081, - math1341, - math1342, - math2331, - cs1210, - cs3000, - cs3650, - thtr1170, - engw3302, - chem1211, - chem1212, - chem1213, - chem1214, - chem1215, - chem1216, - cs3700, - cs3800, - phil1145, - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - ds3000, - cs4400, - cs4500, - cs4501, - cs4300, - eece2160, - ]; - const baseClasses = [ - cs1200, - cs1210, - cs1800, - cs1802, - cs2500, - cs2501, - cs2510, - cs2511, - cs2800, - cs2801, - cs3000, - cs3500, - cs3650, - cs3700, - cs3800, - cs4400, - cs4500, - cs4501, - thtr1170, - cs4300, - cs2810, - ds3000, - math1341, - math1342, - math2331, - math3081, - phil1145, - eece2160, - ]; - const engReqs = [engw1111, engw3302]; - const physReqs = [ - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - ]; - const chemReqs = [ - chem1211, - chem1212, - chem1213, - chem1214, - chem1215, - chem1216, - ]; - const expectedWithChem = solution(...baseClasses, ...chemReqs, ...engReqs); - const expectedWithPhys = solution(...baseClasses, ...physReqs, ...engReqs); - const scheduleCourses = taken.map(convert); - const actual = validateMajor(bscs2, scheduleCourses); - assert.deepStrictEqual(actual, Ok([expectedWithChem, expectedWithPhys])); - }); -}); - -const MajorErr = ( - reqsError?: MajorValidationError, - creditsError?: TotalCreditsRequirementError, -): MajorValidationResult => { - return { - err: { - majorRequirementsError: reqsError, - totalCreditsRequirementError: creditsError, - }, - type: ResultType.Err, - }; -}; - -const Major = ( - requirementSections: Section[], - name = "Demo Major", - yearVersion = 0, - totalCreditsRequired = 0, -): Major => { - return { - name: name, - totalCreditsRequired: totalCreditsRequired, - yearVersion: yearVersion, - requirementSections: requirementSections, - concentrations: { - minOptions: 0, - concentrationOptions: [], - }, - }; -}; - -describe("NoSolution and UnsatChild", () => { - const capstone = section("Capstone", 1, [ - or(course("CS", 4100), course("CS", 4300)), - ]); - const elective = section("Elective", 1, [ - xom(8, [ - range(0, "CS", 2500, 5010, []), - range(0, "DS", 2000, 4900, []), - range(0, "IS", 2000, 4900, []), - ]), - ]); - const presentation = section("Presentation", 1, [course("THTR", 1170)]); - - const base = Major([capstone, elective, presentation]); - const courses = [course("CS", 4100), course("CS", 4300)]; - const convertedCourses = courses.map(convert); - // Helper to wrap errors - validateMajor wraps requirements in an extra AND - const wrapInAnd = (error: MajorValidationError): MajorValidationError => - ({ - type: "AND" as const, - error: { - type: "AND_UNSAT_CHILD" as const, - childErrors: [{ childIndex: 0, ...error }], - }, - }) as MajorValidationError; - - // Former bug case - test("Base Case", () => { - const actual = validateMajor(base, convertedCourses); - assert.deepStrictEqual( - actual, - MajorErr( - wrapInAnd( - AndErrorUnsatChildAndNoSolution( - [ - child( - SectionError(presentation, [courseErr("THTR", 1170, 0)], 0), - 2, - ), - ], - 1, - ), - ), - ), - ); - }); - - // All indices should stay the same - const order1 = Major([elective, capstone, presentation]); - test("Order Change 1", () => { - const actual = validateMajor(order1, convertedCourses); - assert.deepStrictEqual( - actual, - MajorErr( - wrapInAnd( - AndErrorUnsatChildAndNoSolution( - [ - child( - SectionError(presentation, [courseErr("THTR", 1170, 0)], 0), - 2, - ), - ], - 1, - ), - ), - ), - ); - }); - - // UnSat and NoSolution index change. - const order2 = Major([presentation, elective, capstone]); - test("Order Change 2", () => { - const actual = validateMajor(order2, convertedCourses); - assert.deepStrictEqual( - actual, - MajorErr( - wrapInAnd( - AndErrorUnsatChildAndNoSolution( - [ - child( - SectionError(presentation, [courseErr("THTR", 1170, 0)], 0), - 0, - ), - ], - 2, - ), - ), - ), - ); - }); - - // If there are enough courses for both, no NoSolution - const enough = [ - course("CS", 4100), - course("CS", 4300), - course("CS", 4410), - ].map(convert); - test("Enough Courses", () => { - const actual = validateMajor(base, enough); - assert.deepStrictEqual( - actual, - MajorErr( - wrapInAnd( - AndErrorUnsatChild([ - child( - SectionError(presentation, [courseErr("THTR", 1170, 0)], 0), - 2, - ), - ]), - ), - ), - ); - }); -}); - -const courseErr = ( - subject: string, - courseNum: number, - childIndex: number, -): ChildError => { - return child(CourseError(course(subject, courseNum)), childIndex); -}; diff --git a/apps/searchneu/lib/graduate/validation-worker/major-validation.ts b/apps/searchneu/lib/graduate/validation-worker/major-validation.ts deleted file mode 100644 index fa37bbd1..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/major-validation.ts +++ /dev/null @@ -1,1035 +0,0 @@ -import { - Concentrations, - IAndCourse, - ICourseRange, - IOrCourse, - IRequiredCourse, - IXofManyCourse, - Major, - Requirement, - AuditCourse, - IAuditCourse, - Section, - ResultType, - Result, - Err, - Ok, - Minor, -} from "../types"; -import { assertUnreachable, courseToString } from "./course-utils"; - -const UNDECIDED_STRING = "Undecided"; - -/** - * General solution: postorder traversal requirements, producing all solutions - * at each level. inductive step: combine child solutions to produce solutions - * for ourselves - */ - -// ------------------------ TYPES ------------------------ - -/** - * A single solution, containing a list of courseToString(c) + # of credits - * satisfied (needed for XOM) - */ -type Solution = { - minCredits: number; - maxCredits: number; - sol: Array; -}; - -/** - * Concentrations are specified by their name or index in the accompanying - * concentrations list - */ -type SelectedConcentrationsType = number | string | (number | string)[]; - -// Error types and constructors -export type MajorValidationError = - | CourseError - | AndError - | OrError - | XOMError - | SectionError; -export const MajorValidationErrorType = { - Course: "COURSE", - Range: "RANGE", - And: { - Type: "AND", - UnsatChild: "AND_UNSAT_CHILD", - NoSolution: "AND_NO_SOLUTION", - UnsatChildAndNoSolution: "AND_UNSAT_CHILD_AND_NO_SOLUTION", - }, - Or: "OR", - XofMany: "XOM", - Section: "SECTION", -} as const; -export type ChildError = MajorValidationError & { childIndex: number }; -type CourseError = { - type: typeof MajorValidationErrorType.Course; - requiredCourse: string; -}; - -export function CourseError(c: IRequiredCourse): CourseError { - return { - type: MajorValidationErrorType.Course, - requiredCourse: courseToString(c), - }; -} - -type AndError = { - type: typeof MajorValidationErrorType.And.Type; - error: - | { - type: typeof MajorValidationErrorType.And.UnsatChild; - childErrors: Array; - } - | { - type: typeof MajorValidationErrorType.And.NoSolution; - discoveredAtChild: number; - } - | { - type: typeof MajorValidationErrorType.And.UnsatChildAndNoSolution; - noSolution: { - type: typeof MajorValidationErrorType.And.NoSolution; - discoveredAtChild: number; - }; - unsatChildErrors: { - type: typeof MajorValidationErrorType.And.UnsatChild; - childErrors: Array; - }; - }; -}; - -export const AndErrorUnsatChildAndNoSolution = ( - unsatChildErrors: Array, - noSolutionIndex: number, -): AndError => { - return { - type: MajorValidationErrorType.And.Type, - error: { - type: MajorValidationErrorType.And.UnsatChildAndNoSolution, - noSolution: { - type: MajorValidationErrorType.And.NoSolution, - discoveredAtChild: noSolutionIndex, - }, - unsatChildErrors: { - type: MajorValidationErrorType.And.UnsatChild, - childErrors: unsatChildErrors, - }, - }, - }; -}; - -export const AndErrorUnsatChild = ( - childErrors: Array, -): AndError => ({ - type: MajorValidationErrorType.And.Type, - error: { type: MajorValidationErrorType.And.UnsatChild, childErrors }, -}); -export const AndErrorNoSolution = (idx: number): AndError => ({ - type: MajorValidationErrorType.And.Type, - error: { - type: MajorValidationErrorType.And.NoSolution, - discoveredAtChild: idx, - }, -}); -type OrError = { - type: typeof MajorValidationErrorType.Or; - childErrors: Array; -}; -const OrError = (childErrors: Array): OrError => ({ - type: MajorValidationErrorType.Or, - childErrors, -}); -type XOMError = { - type: typeof MajorValidationErrorType.XofMany; - childErrors: Array; - minRequiredCredits: number; - maxPossibleCredits: number; -}; -const XOMError = ( - r: IXofManyCourse, - childErrors: Array, - maxPossibleCredits: number, -): XOMError => ({ - type: MajorValidationErrorType.XofMany, - childErrors, - minRequiredCredits: r.numCreditsMin, - maxPossibleCredits, -}); -type SectionError = { - type: typeof MajorValidationErrorType.Section; - sectionTitle: string; - childErrors: Array; - minRequiredChildCount: number; - maxPossibleChildCount: number; -}; -export const SectionError = ( - r: Section, - childErrors: Array, - max: number, -): SectionError => ({ - type: MajorValidationErrorType.Section, - sectionTitle: r.title, - childErrors, - minRequiredChildCount: r.minRequirementCount, - maxPossibleChildCount: max, -}); -export type TotalCreditsRequirementError = { - takenCredits: number; - requiredCredits: number; -}; - -// Custom error class for validation input errors -export class MajorValidationInputError extends Error { - constructor( - message: string, - public readonly field: string, - public readonly receivedValue?: unknown, - ) { - super(message); - this.name = "MajorValidationInputError"; - } -} - -// Validates the input parameters for validateMajor -function validateInputs( - major: Major | null | undefined, - taken: AuditCourse[] | null | undefined, -): asserts major is Major { - if (major === null || major === undefined) { - throw new MajorValidationInputError( - "Major is required for validation", - "major", - major, - ); - } - - if (typeof major !== "object") { - throw new MajorValidationInputError( - `Major must be an object, received ${typeof major}`, - "major", - major, - ); - } - - if (!major.name || typeof major.name !== "string") { - throw new MajorValidationInputError( - "Major must have a valid name", - "major.name", - major.name, - ); - } - - if (!Array.isArray(major.requirementSections)) { - throw new MajorValidationInputError( - "Major must have requirementSections array", - "major.requirementSections", - major.requirementSections, - ); - } - - if ( - typeof major.totalCreditsRequired !== "number" || - major.totalCreditsRequired < 0 - ) { - throw new MajorValidationInputError( - "Major must have a valid totalCreditsRequired (non-negative number)", - "major.totalCreditsRequired", - major.totalCreditsRequired, - ); - } - - if (taken === null || taken === undefined) { - throw new MajorValidationInputError( - "Taken courses array is required for validation", - "taken", - taken, - ); - } - - if (!Array.isArray(taken)) { - throw new MajorValidationInputError( - `Taken courses must be an array, received ${typeof taken}`, - "taken", - taken, - ); - } - - // Validate each course in the taken array has required fields - for (let i = 0; i < taken.length; i++) { - const course = taken[i]; - if (!course || typeof course !== "object") { - throw new MajorValidationInputError( - `Invalid course at index ${i}: must be an object`, - `taken[${i}]`, - course, - ); - } - if (!course.subject || typeof course.subject !== "string") { - throw new MajorValidationInputError( - `Invalid course at index ${i}: missing or invalid subject`, - `taken[${i}].subject`, - course.subject, - ); - } - if (!course.classId || typeof course.classId !== "string") { - throw new MajorValidationInputError( - `Invalid course at index ${i}: missing or invalid classId`, - `taken[${i}].classId`, - course.classId, - ); - } - if (typeof course.numCreditsMin !== "number") { - throw new MajorValidationInputError( - `Invalid course at index ${i}: missing or invalid numCreditsMin`, - `taken[${i}].numCreditsMin`, - course.numCreditsMin, - ); - } - } -} - -// Validates minor input if provided -function validateMinorInput(minor: Minor | undefined): void { - if (minor === undefined) { - return; - } - - if (typeof minor !== "object" || minor === null) { - throw new MajorValidationInputError( - `Minor must be an object, received ${typeof minor}`, - "minor", - minor, - ); - } - - if (!minor.name || typeof minor.name !== "string") { - throw new MajorValidationInputError( - "Minor must have a valid name", - "minor.name", - minor.name, - ); - } - - if (!Array.isArray(minor.requirementSections)) { - throw new MajorValidationInputError( - "Minor must have requirementSections array", - "minor.requirementSections", - minor.requirementSections, - ); - } - - if ( - typeof minor.totalCreditsRequired !== "number" || - minor.totalCreditsRequired < 0 - ) { - throw new MajorValidationInputError( - "Minor must have a valid totalCreditsRequired (non-negative number)", - "minor.totalCreditsRequired", - minor.totalCreditsRequired, - ); - } -} - -// for keeping track of courses taken -interface CourseValidationTracker { - // retrieve a given schedule course if it exists - // validation algorithm shouldn't care about the id so we use unknown instead of any/null - get(input: IAuditCourse): AuditCourse | null; - - // retrieves the number of times a course has been taken - getCount(input: IAuditCourse): number; - - // retrieves all matching courses (subject, and within start/end inclusive) - getAll(subject: string, start: number, end: number): Array; - - // do we have enough courses to take all classes in both solutions? - hasEnoughCoursesForBoth(s1: Solution, s2: Solution): boolean; - - setNecessaryCourses(courses: Set): void; - - getNecessaryCourses(): Set; -} - -// exported for testing -export class MajorValidationTracker implements CourseValidationTracker { - // maps courseString => [course instance, # of times taken] - private currentCourses: Map; - - //list of degree-required courses that we should not consider in the range validator - private necessaryCourses: Set = new Set(); - - constructor(courses: AuditCourse[]) { - this.currentCourses = new Map(); - for (const c of courses) { - const cs = courseToString(c); - let tup = this.currentCourses.get(cs); - if (!tup) { - // assume each course instance is the same - tup = [c, 0]; - this.currentCourses.set(cs, tup); - } - tup[1] += 1; - } - } - - get(input: IAuditCourse) { - const course = this.currentCourses.get(courseToString(input)); - if (course) { - return course[0]; - } - return null; - } - - getCount(input: IAuditCourse) { - return this.currentCourses.get(courseToString(input))?.[1] ?? 0; - } - - getAll(subject: string, start: number, end: number) { - return Array.from(this.currentCourses.values()).flatMap(([c, count]) => { - const cid = Number(c.classId); - const valid = c.subject === subject && cid >= start && cid <= end; - if (!valid) return []; - return Array(count).fill(c); - }); - } - - hasEnoughCoursesForBoth(s1: Solution, s2: Solution) { - const s1map = MajorValidationTracker.createTakenMap(s1); - const s2map = MajorValidationTracker.createTakenMap(s2); - // iterate through the solution with fewer courses for speed - const [fst, snd] = - s1.sol.length < s2.sol.length ? [s1map, s2map] : [s2map, s1map]; - // for all courses in both solutions, check we have enough courses - for (const [cs, fstCount] of fst) { - const sndCount = snd.get(cs); - // if not in second solution, we have enough (skip) - if (!sndCount) continue; - const neededCount = fstCount + sndCount; - const tup = this.currentCourses.get(cs); - if (!tup) { - throw new Error("Solution contained a course that the tracker did not"); - } - const actualCount = tup[1]; - // if we don't have enough, return false, otherwise continue - if (actualCount < neededCount) { - return false; - } - } - return true; - } - - setNecessaryCourses(courses: Set) { - this.necessaryCourses = courses; - } - - getNecessaryCourses() { - return this.necessaryCourses; - } - - // Maps the # of each course required in the given solution - private static createTakenMap(s: Solution): Map { - const map = new Map(); - for (const c of s.sol) { - const val = map.get(c) ?? 0; - map.set(c, val + 1); - } - return map; - } -} - -// the result of major validation -export type MajorValidationResult = Result< - Solution[], - { - majorRequirementsError?: MajorValidationError; - totalCreditsRequirementError?: TotalCreditsRequirementError; - } ->; - -export function validateMajor( - major: Major, - taken: AuditCourse[], - minor?: Minor, - concentrations?: SelectedConcentrationsType, -): MajorValidationResult { - // Validate all inputs before processing - validateInputs(major, taken); - validateMinorInput(minor); - - const tracker = new MajorValidationTracker(taken); - - let concentrationReq: Requirement[] = []; - if (major.concentrations) { - try { - concentrationReq = getConcentrationsRequirement( - concentrations, - major.concentrations, - ); - } catch (error) { - if (error instanceof Error) { - throw new MajorValidationInputError( - `Failed to process concentrations: ${error.message}`, - "concentrations", - concentrations, - ); - } - throw error; - } - } - - const minorRequirements: Requirement[] = []; - if (minor) { - // Get the minor requirements and assign them - minorRequirements.push(...getMinorRequirement(minor)); - } - - const majorRequirements: Requirement[] = wrapMajor(major); - - const allRequirements = [ - ...majorRequirements, - ...minorRequirements, - ...concentrationReq, - ]; - - const requiredCourses: Set = new Set(); - tracker.setNecessaryCourses( - getNecessaryCourses(allRequirements, requiredCourses), - ); - - // create a big AND requirement of all the sections and selected concentrations - const requirementsResult = validateRequirement( - { - type: "AND", - courses: allRequirements, - }, - tracker, - ); - - const creditsResult = validateTotalCreditsRequired( - major.totalCreditsRequired + (minor?.totalCreditsRequired ?? 0), - taken, - ); - - const [solutions, majorRequirementsError] = - requirementsResult.type === ResultType.Ok - ? [requirementsResult.ok, undefined] - : [undefined, requirementsResult.err]; - if (solutions) { - return Ok(solutions); - } - const totalCreditsRequirementError = - creditsResult.type === ResultType.Ok ? undefined : creditsResult.err; - return Err({ - majorRequirementsError, - totalCreditsRequirementError, - }); -} - -/** - * Crawls through the requirements, producing a list of necessary courses. This - * is used to filter out courses that cannot be used for the range. - */ -export function getNecessaryCourses( - requirements: Requirement[], - requiredCourses: Set, -): Set { - const tracker = new MajorValidationTracker([]); - - for (const req of requirements) { - crawlRequirement(req, tracker, requiredCourses); - } - return requiredCourses; -} - -/** Crawls through the requirements, producing a list of necessary courses. */ -function crawlRequirement( - req: Requirement, - tracker: CourseValidationTracker, - requiredCourses: Set, -): void { - switch (req.type) { - // base cases, a course is added to the list of necessary courses - case "COURSE": - requiredCourses.add(courseToString(req)); - break; - // inductive case, we crawl through the children of an AND requirement - case "AND": - req.courses.forEach((r) => crawlRequirement(r, tracker, requiredCourses)); - break; - // inductive case, we crawl through the children of a whole section - case "SECTION": - req.requirements.forEach((r) => - crawlRequirement(r, tracker, requiredCourses), - ); - break; - case "XOM": - case "OR": - case "RANGE": - break; - default: - return assertUnreachable(); - } -} - -/** - * Produces the selected input concentrations to be included in major validation. - * - * @param inputConcentrations The concentrations to include - * @param concentrationsRequirement All available concentrations - */ -export function getConcentrationsRequirement( - inputConcentrations: undefined | SelectedConcentrationsType, - concentrationsRequirement: Concentrations, -): Requirement[] { - const selectedConcentrations = - convertToConcentrationsArray(inputConcentrations); - if (concentrationsRequirement.concentrationOptions.length === 0) { - return []; - } - // Allow undecided concentrations - if (inputConcentrations === UNDECIDED_STRING) { - return []; - } - const concentrationRequirements = []; - for (const c of selectedConcentrations) { - const found = concentrationsRequirement.concentrationOptions.find( - (cf, idx) => { - switch (typeof c) { - case "number": - return c === idx; - case "string": - return c === cf.title; - default: - return assertUnreachable(); - } - }, - ); - if (!found) { - const msg = `Concentration specified was not found in the major: ${c}`; - throw new Error(msg); - } - concentrationRequirements.push(found); - } - return [{ type: "AND", courses: concentrationRequirements }]; -} - -// normalizes input to an array of strings and numbers -function convertToConcentrationsArray( - concentrations: undefined | string | number | (string | number)[], -) { - if (concentrations === undefined) { - return []; - } - if ( - typeof concentrations === "string" || - typeof concentrations === "number" - ) { - return [concentrations]; - } - return concentrations; -} - -export function wrapMajor(inputMajor: Major): Requirement[] { - const majorRequirements: Section[] = inputMajor.requirementSections; - return [{ type: "AND", courses: majorRequirements }]; -} - -export function getMinorRequirement( - inputMinor: undefined | Minor, -): Requirement[] { - // No minor - if (!inputMinor) { - return []; - } - - // put all the minor requirments into minor requirments and assigning type as section - const minorRequirements: Section[] = inputMinor.requirementSections; - - return [{ type: "AND", courses: minorRequirements }]; -} - -// the solutions returned may have duplicate courses, indicating the # of times a course is taken -export const validateRequirement = ( - req: Requirement, - tracker: CourseValidationTracker, -): Result, MajorValidationError> => { - switch (req.type) { - // base cases - case "RANGE": - return validateRangeRequirement(req, tracker); - case "COURSE": - return validateCourseRequirement(req, tracker); - // inductive cases - case "AND": - return validateAndRequirement(req, tracker); - case "XOM": - return validateXomRequirement(req, tracker); - case "OR": - return validateOrRequirement(req, tracker); - case "SECTION": - return validateSectionRequirement(req, tracker); - default: - return assertUnreachable(); - } -}; - -function validateTotalCreditsRequired( - requiredCredits: number, - coursesTaken: AuditCourse[], -): Result { - const takenCredits = coursesTaken.reduce( - (total, course) => total + course.numCreditsMin, - 0, - ); - - if (takenCredits < requiredCredits) { - return Err({ - takenCredits, - requiredCredits, - }); - } - return Ok(null); -} - -function validateCourseRequirement( - r: IRequiredCourse, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - const c = tracker.get(r); - if (c) { - return Ok([ - { - minCredits: c.numCreditsMin, - maxCredits: c.numCreditsMax, - sol: [courseToString(c)], - }, - ]); - } - return Err(CourseError(r)); -} - -function validateRangeRequirement( - r: ICourseRange, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - // get the eligible courses (Filter out exceptions) - const exceptions = new Set(r.exceptions.map(courseToString)); - tracker.getNecessaryCourses().forEach((course) => exceptions.add(course)); - - const courses = tracker - .getAll(r.subject, r.idRangeStart, r.idRangeEnd) - .filter((c) => !exceptions.has(courseToString(c))); - - const solutionsSoFar: Array = []; - - // produce all combinations of the courses - for (const course of courses) { - const solutionsSoFarWithCourse: Array = []; - const cs = courseToString(course); - const courseSol = { - sol: [cs], - minCredits: course.numCreditsMin, - maxCredits: course.numCreditsMax, - }; - - // Adds the current course to all previous valid solutions if there are - // enough courses. - for (const solutionSoFar of solutionsSoFar) { - // TODO: if i take a course twice, can both count in the same range? - // for now assume yes. but ask khoury, then remove this note - if (tracker.hasEnoughCoursesForBoth(solutionSoFar, courseSol)) { - const currentSol: Solution = combineSolutions(solutionSoFar, courseSol); - solutionsSoFarWithCourse.push(currentSol); - } - } - // include solutions where the only course is ourself - solutionsSoFarWithCourse.push(courseSol); - solutionsSoFar.push(...solutionsSoFarWithCourse); - } - return Ok(solutionsSoFar); -} - -/** - * Example: (CS2810 or CS2800) and (CS2810 or DS3000) - * - * Child Solutions: - * - * Child 1: - * - * - Solution 1: { min: 4, max: 4, sol: [CS2810]} - * - Solution 2: { min: 4, max: 4, sol: [CS2800]} Child 2: - * - Solution 1: { min: 4, max: 4, sol: [CS2810]} - * - Solution 2: { min: 4, max: 4, sol: [DS3000]} - * - * For each of the sols so far, try combining with each solution of child 1: - * solsSoFar = [[]] - * - * Try combining base solution with c1s1. It works! solsSoFarWithChild = [[CS2810]] - * - * Try combining base solution with c1s2. It works! solsSoFarWithChild = - * [[CS2810], [CS2800]] - * - * Done with Child 1! set solsSoFar <- solsSoFarWithChild - * - * For each of the sols so far, try combining with each solution of child 2: Try - * combining solsSoFar[0] = [CS2810] with c2s1 = [CS2810]. It doesn't work-- - * (CS2810 twice) solsSoFarWithChild = [] - * - * Try combining solsSoFar[0] = [CS2810] with c2s2 = [DS3000]. It works! - * solsSoFarWithChild = [[CS2810, DS3000]] - * - * // next solSoFar - * - * Try combining solsSoFar[1] = [CS2800] with c2s1 = [CS2810]. It works! - * solsSoFarWithChild = [[CS2810, DS3000], [CS2800, CS2810]] - * - * Try combining solsSoFar[1] = [CS2800] with c2s2 = [DS3000]. It works! - * solsSoFarWithChild = [[CS2810, DS3000], [CS2800, CS2810], [CS2800, DS3000]] - * - * That was the last child, so we are done! - */ -function validateAndRequirement( - r: IAndCourse, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - const results = validateRequirements(r.courses, tracker); - const [allChildReqSolutions, childErrors] = splitChildResults(results); - - // valid solutions for all the requirements so far - let solutionsSoFar: Array = [ - { maxCredits: 0, minCredits: 0, sol: [] }, - ]; - - // Diff solutions of each requirement in the AND - for (const childRequirementSolutions of allChildReqSolutions.values()) { - const solutionsSoFarWithChild: Array = []; - for (const solutionSoFar of solutionsSoFar) { - // Each solution of each subsolution - for (const childSolution of childRequirementSolutions) { - // if the intersection of us and the solution so far is empty, combine them and add to current solutions - if (tracker.hasEnoughCoursesForBoth(childSolution, solutionSoFar)) { - solutionsSoFarWithChild.push( - combineSolutions(solutionSoFar, childSolution), - ); - } - } - } - // if there were no solutions added, then there are no valid solutions for the whole AND - if (solutionsSoFarWithChild.length === 0) { - const actualIndex = results.findIndex((solution) => { - return ( - solution.type === ResultType.Ok && - solution.ok === childRequirementSolutions - ); - }); - if (childErrors.length > 0) { - return Err(AndErrorUnsatChildAndNoSolution(childErrors, actualIndex)); - } else { - return Err(AndErrorNoSolution(actualIndex)); - } - } - solutionsSoFar = solutionsSoFarWithChild; - } - - // AND's children has errors - if (childErrors.length > 0) { - return Err(AndErrorUnsatChild(childErrors)); - } - - return Ok(solutionsSoFar); -} - -// find all combinations with total credits >= # required credits (kinda) -function validateXomRequirement( - r: IXofManyCourse, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - const splitResults = validateAndSplit(r.courses, tracker); - const [allChildRequirementSolutions, childErrors] = splitResults; - // error if there are no solutions, and at least 1 credit is required - if (allChildRequirementSolutions.length === 0 && r.numCreditsMin > 0) { - return Err(XOMError(r, childErrors, 0)); - } - - // solutions w #totalcredits < #required - const unfinishedSolutionsSoFar: Array = []; - // solutions w #totalCredits >= #required - const finishedSolutions: Array = []; - - for (const childRequirementSolutions of allChildRequirementSolutions) { - const unfinishedSolutionsWithChild: Array = []; - // for each child, try each childSolution with each unfinishedSolution - for (const childSolution of childRequirementSolutions) { - for (const solutionSoFar of unfinishedSolutionsSoFar) { - // if we have enough credits for both, add it - if (tracker.hasEnoughCoursesForBoth(childSolution, solutionSoFar)) { - const currentSol = combineSolutions(solutionSoFar, childSolution); - // Check if the min credit requirement is met, if it is - // I don't need to build on this solution so add to finished - if (currentSol.minCredits >= r.numCreditsMin) { - finishedSolutions.push(currentSol); - } else { - unfinishedSolutionsWithChild.push(currentSol); - } - } - } - // consider the by itself as well (possible we don't take any prior solutions) - if (childSolution.minCredits >= r.numCreditsMin) { - finishedSolutions.push(childSolution); - } else { - unfinishedSolutionsWithChild.push(childSolution); - } - } - // add all child+unfinished combinations to unfinished - unfinishedSolutionsSoFar.push(...unfinishedSolutionsWithChild); - } - if (finishedSolutions.length > 0) { - return Ok(finishedSolutions); - } - // Find the sol with the max credits, as use that as your error - const max = unfinishedSolutionsSoFar.reduce( - (a, b) => Math.max(a, b.minCredits), - 0, - ); - return Err(XOMError(r, childErrors, max)); -} - -function validateOrRequirement( - r: IOrCourse, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - // just return concatenated list of child solutions - const [oks, errs] = validateAndSplit(r.courses, tracker); - if (oks.length === 0) { - return Err(OrError(errs)); - } - return Ok(oks.flat()); -} - -function validateSectionRequirement( - r: Section, - tracker: CourseValidationTracker, -): Result, MajorValidationError> { - if (r.minRequirementCount < 1) { - return Ok([]); - // this should be an invalid shape and throw an error, but for now we'll just return an empty array - // since the solution for a section with no requirements is an empty array - throw new Error("Section requirement count must be >= 1"); - } - - const splitResults = validateAndSplit(r.requirements, tracker); - const [allChildRequirementSolutions, childErrors] = splitResults; - // we must have at least the min required # of child solutions - if (allChildRequirementSolutions.length < r.minRequirementCount) { - return Err( - SectionError(r, childErrors, allChildRequirementSolutions.length), - ); - } - - type Solution1 = Solution & { count: number }; - // invariant: requirementCount of unfinished solutions < minRequirementCount - const unfinishedSolutionsSoFar: Array = []; - // solutions where requirement count === minRequirementCount - const finishedSolutions: Array = []; - - for (const childRequirementSolutions of allChildRequirementSolutions) { - const unfinishedSolutionsWithChild: Array = []; - // for each child, try each childSolution with each unfinishedSolution - for (const childSolution of childRequirementSolutions) { - for (const { - count: solutionSoFarCount, - ...solutionSoFar - } of unfinishedSolutionsSoFar) { - // if enough for both, combine them, then add to corresponding list - if (tracker.hasEnoughCoursesForBoth(childSolution, solutionSoFar)) { - const currentSol = combineSolutions(solutionSoFar, childSolution); - const currentSolCount = solutionSoFarCount + 1; - if (currentSolCount === r.minRequirementCount) { - finishedSolutions.push(currentSol); - } else { - unfinishedSolutionsWithChild.push({ - ...currentSol, - count: currentSolCount, - }); - } - } - } - // same as XOM, consider the solutions where we don't take prior child solutions - // push single child solution by itself - if (r.minRequirementCount === 1) { - finishedSolutions.push(childSolution); - } else { - unfinishedSolutionsWithChild.push({ ...childSolution, count: 1 }); - } - } - unfinishedSolutionsSoFar.push(...unfinishedSolutionsWithChild); - } - if (finishedSolutions.length > 0) { - return Ok(finishedSolutions); - } - const max = unfinishedSolutionsSoFar.reduce( - (a, b) => Math.max(a, b.count), - 0, - ); - return Err(SectionError(r, childErrors, max)); -} - -function combineSolutions(s1: Solution, s2: Solution) { - return { - minCredits: s1.minCredits + s2.minCredits, - maxCredits: s1.maxCredits + s2.maxCredits, - sol: [...s1.sol, ...s2.sol], - }; -} - -// validates children and splits their results into solutions and errors -function validateAndSplit( - rs: Requirement[], - tracker: CourseValidationTracker, -): [Solution[][], Array] { - const results = validateRequirements(rs, tracker); - return splitChildResults(results); -} - -function splitChildResults( - reqs: Result[], -): [Solution[][], Array] { - const oks = []; - const errs = []; - for (let i = 0; i < reqs.length; i += 1) { - const result = reqs[i]; - if (result.type === ResultType.Ok) oks.push(result.ok); - else errs.push({ ...result.err, childIndex: i }); - } - return [oks, errs]; -} - -function validateRequirements( - rs: Requirement[], - tracker: CourseValidationTracker, -) { - while (rs.some((r) => Array.isArray(r))) { - const newRs = []; - for (const r of rs) { - if (Array.isArray(r)) { - newRs.push(...extractRequirements(r)); - } else { - newRs.push(r); - } - } - rs = newRs; - } - - return rs.map((r) => validateRequirement(r, tracker)); -} - -const extractRequirements = (requirements: Requirement[]): Requirement[] => { - const extracted: Requirement[] = []; - for (const value of requirements) { - extracted.push(value); - } - return extracted; -}; diff --git a/apps/searchneu/lib/graduate/validation-worker/mock-courses.ts b/apps/searchneu/lib/graduate/validation-worker/mock-courses.ts deleted file mode 100644 index d7951040..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/mock-courses.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { course } from "./test-utils"; -const cs2810 = course("CS", 2810); -const cs3950 = course("CS", 3950); -const cs4805 = course("CS", 4805); -const cs4810 = course("CS", 4810); -const cs4830 = course("CS", 4830); -const cs4820 = course("CS", 4820); -const cs4950 = course("CS", 4950, 1); -const ds3000 = course("DS", 3000); -const cy4770 = course("CS", 4770); -const cs1200 = course("CS", 1200, 1); -const cs1210 = course("CS", 1210, 1); -const cs1800 = course("CS", 1800); -const cs1802 = course("CS", 1802, 1); -const cs2500 = course("CS", 2500); -const cs2501 = course("CS", 2501, 1); -const cs2510 = course("CS", 2510); -const cs2511 = course("CS", 2511, 1); -const cs2800 = course("CS", 2800); -const cs2801 = course("CS", 2801, 1); -const cs3000 = course("CS", 3000); -const cs3500 = course("CS", 3500); -const cs3650 = course("CS", 3650); -const cs3700 = course("CS", 3700); -const cs3800 = course("CS", 3800); -const cs4400 = course("CS", 4400); -const cs4500 = course("CS", 4500); -const cs4501 = course("CS", 4501); -const thtr1170 = course("THTR", 1170, 1); -const cs4410 = course("CS", 4410); -const cs2550 = course("CS", 2550); -const ds4300 = course("DS", 4300); -const cs4300 = course("CS", 4300); -const math1341 = course("MATH", 1341); -const math1342 = course("MATH", 1342); -const math2331 = course("MATH", 2331); -const math3081 = course("MATH", 3081); -const phil1145 = course("PHIL", 1145); -const eece2160 = course("EECE", 2160); -const chem1211 = course("CHEM", 1211, 3); -const chem1212 = course("CHEM", 1212, 1); -const chem1213 = course("CHEM", 1213, 1); -const chem1214 = course("CHEM", 1214, 3); -const chem1215 = course("CHEM", 1215, 1); -const chem1216 = course("CHEM", 1216, 1); -const phys1151 = course("PHYS", 1151, 3); -const phys1152 = course("PHYS", 1152, 1); -const phys1153 = course("PHYS", 1153, 1); -const phys1155 = course("PHYS", 1155, 3); -const phys1156 = course("PHYS", 1156, 1); -const phys1157 = course("PHYS", 1157, 1); -const engw1111 = course("ENGW", 1111); -const engw3302 = course("ENGW", 3302); -const cs1990 = course("CS", 1990); -const hist1130 = course("HIST", 1130); -const math2321 = course("MATH", 2321); -const honr1310 = course("HONR", 1310); -const math3527 = course("MATH", 3527); -const artg1250 = course("ARTG", 1250); -const artg2400 = course("ARTG", 2400); -export { - cs2810, - cs3950, - cs4805, - cs4810, - cs4830, - cs4820, - cs4950, - ds3000, - cy4770, - cs1200, - cs1210, - cs1800, - cs1802, - cs2500, - cs2501, - cs2510, - cs2511, - cs2800, - cs2801, - cs3000, - cs3500, - cs3650, - cs3700, - cs3800, - cs4400, - cs4500, - cs4501, - thtr1170, - cs4410, - cs2550, - ds4300, - cs4300, - math1341, - math1342, - math2331, - math3081, - phil1145, - eece2160, - chem1211, - chem1212, - chem1213, - chem1214, - chem1215, - chem1216, - phys1151, - phys1152, - phys1153, - phys1155, - phys1156, - phys1157, - engw1111, - engw3302, - cs1990, - hist1130, - math2321, - honr1310, - math3527, - artg1250, - artg2400, -}; diff --git a/apps/searchneu/lib/graduate/validation-worker/mock-majors/bscs.json b/apps/searchneu/lib/graduate/validation-worker/mock-majors/bscs.json deleted file mode 100644 index 2509b6b0..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/mock-majors/bscs.json +++ /dev/null @@ -1,762 +0,0 @@ -{ - "name": "Computer Science, BSCS", - "yearVersion": 2018, - "isLanguageRequired": false, - "totalCreditsRequired": 0, - "concentrations": { - "minOptions": 0, - "maxOptions": 0, - "concentrationOptions": [] - }, - "nupaths": [], - "requirementGroups": [ - "Computer Science Overview", - "Computer Science Fundamental Courses", - "Computer Science Required Courses", - "Presentation Requirement", - "Computer Science Capstone", - "Computer Science Elective Courses", - "Mathematics Courses", - "Computing and Social Issues", - "Electrical Engineering", - "Science Requirement", - "College Writing", - "Advanced Writing in the Disciplines" - ], - "requirementGroupMap": { - "Computer Science Overview": { - "type": "AND", - "name": "Computer Science Overview", - "requirements": [ - { - "type": "COURSE", - "classId": 1200, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 1210, - "subject": "CS" - } - ] - }, - "Computer Science Fundamental Courses": { - "type": "AND", - "name": "Computer Science Fundamental Courses", - "requirements": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1800, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 1802, - "subject": "CS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2500, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 2501, - "subject": "CS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2510, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 2511, - "subject": "CS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2800, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 2801, - "subject": "CS" - } - ] - } - ] - }, - "Computer Science Required Courses": { - "type": "AND", - "name": "Computer Science Required Courses", - "requirements": [ - { - "type": "COURSE", - "classId": 3000, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3500, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3650, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3700, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3800, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4400, - "subject": "CS" - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 4500, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4501, - "subject": "CS" - } - ] - } - ] - }, - "Presentation Requirement": { - "type": "AND", - "name": "Presentation Requirement", - "requirements": [ - { - "type": "COURSE", - "classId": 1170, - "subject": "THTR" - } - ] - }, - "Computer Science Capstone": { - "type": "OR", - "name": "Computer Science Capstone", - "numCreditsMin": 4, - "numCreditsMax": 5, - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "COURSE", - "classId": 4100, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4300, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4410, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4150, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4550, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4991, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4900, - "subject": "IS" - } - ] - } - ] - }, - "Computer Science Elective Courses": { - "type": "RANGE", - "name": "Computer Science Elective Courses", - "numCreditsMin": 8, - "numCreditsMax": 8, - "requirements": { - "type": "RANGE", - "creditsRequired": 8, - "ranges": [ - { - "subject": "CS", - "idRangeStart": 2500, - "idRangeEnd": 5010 - }, - { - "subject": "IS", - "idRangeStart": 2000, - "idRangeEnd": 4900 - }, - { - "subject": "DS", - "idRangeStart": 2000, - "idRangeEnd": 4900 - } - ] - } - }, - "Mathematics Courses": { - "type": "AND", - "name": "Mathematics Courses", - "requirements": [ - { - "type": "COURSE", - "classId": 1341, - "subject": "MATH" - }, - { - "type": "COURSE", - "classId": 1342, - "subject": "MATH" - }, - { - "type": "COURSE", - "classId": 2331, - "subject": "MATH" - }, - { - "type": "COURSE", - "classId": 3081, - "subject": "MATH" - } - ] - }, - "Computing and Social Issues": { - "type": "OR", - "name": "Computing and Social Issues", - "numCreditsMin": 4, - "numCreditsMax": 4, - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "COURSE", - "classId": 3418, - "subject": "ANTH" - }, - { - "type": "COURSE", - "classId": 5240, - "subject": "IA" - }, - { - "type": "COURSE", - "classId": 2102, - "subject": "INSH" - }, - { - "type": "COURSE", - "classId": 1145, - "subject": "PHIL" - }, - { - "type": "COURSE", - "classId": 1280, - "subject": "SOCL" - }, - { - "type": "COURSE", - "classId": 3485, - "subject": "SOCL" - }, - { - "type": "COURSE", - "classId": 4528, - "subject": "SOCL" - } - ] - } - ] - }, - "Electrical Engineering": { - "type": "AND", - "name": "Electrical Engineering", - "requirements": [ - { - "type": "COURSE", - "classId": 2160, - "subject": "EECE" - } - ] - }, - "Science Requirement": { - "type": "OR", - "name": "Science Requirement", - "numCreditsMin": 10, - "numCreditsMax": 10, - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1111, - "subject": "BIOL" - }, - { - "type": "COURSE", - "classId": 1112, - "subject": "BIOL" - } - ] - }, - { - "type": "OR", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1113, - "subject": "BIOL" - }, - { - "type": "COURSE", - "classId": 1114, - "subject": "BIOL" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2301, - "subject": "BIOL" - }, - { - "type": "COURSE", - "classId": 2302, - "subject": "BIOL" - } - ] - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1211, - "subject": "CHEM" - }, - { - "type": "COURSE", - "classId": 1212, - "subject": "CHEM" - }, - { - "type": "COURSE", - "classId": 1213, - "subject": "CHEM" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1214, - "subject": "CHEM" - }, - { - "type": "COURSE", - "classId": 1215, - "subject": "CHEM" - }, - { - "type": "COURSE", - "classId": 1216, - "subject": "CHEM" - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1200, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 1201, - "subject": "ENVR" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1202, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 1203, - "subject": "ENVR" - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1200, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 1201, - "subject": "ENVR" - } - ] - }, - { - "type": "OR", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2310, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 2311, - "subject": "ENVR" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 2340, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 2341, - "subject": "ENVR" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 3300, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 3301, - "subject": "ENVR" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 4500, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 4501, - "subject": "ENVR" - } - ] - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1202, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 1203, - "subject": "ENVR" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 5242, - "subject": "ENVR" - }, - { - "type": "COURSE", - "classId": 5243, - "subject": "ENVR" - } - ] - } - ] - }, - { - "type": "OR", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1145, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1146, - "subject": "PHYS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1147, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1148, - "subject": "PHYS" - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1151, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1152, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1153, - "subject": "PHYS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1155, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1156, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1157, - "subject": "PHYS" - } - ] - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1161, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1162, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1163, - "subject": "PHYS" - } - ] - }, - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1165, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1166, - "subject": "PHYS" - }, - { - "type": "COURSE", - "classId": 1167, - "subject": "PHYS" - } - ] - } - ] - } - ] - } - ] - } - ] - }, - "College Writing": { - "type": "AND", - "name": "College Writing", - "requirements": [ - { - "type": "COURSE", - "classId": 1111, - "subject": "ENGW" - } - ] - }, - "Advanced Writing in the Disciplines": { - "type": "AND", - "name": "Advanced Writing in the Disciplines", - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "COURSE", - "classId": 3302, - "subject": "ENGW" - }, - { - "type": "COURSE", - "classId": 3315, - "subject": "ENGW" - } - ] - } - ] - } - } -} diff --git a/apps/searchneu/lib/graduate/validation-worker/mock-majors/requirementTest.json b/apps/searchneu/lib/graduate/validation-worker/mock-majors/requirementTest.json deleted file mode 100644 index 6c5e2a2e..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/mock-majors/requirementTest.json +++ /dev/null @@ -1,162 +0,0 @@ -{ - "name": "Requirement Types", - "yearVersion": 2024, - "isLanguageRequired": false, - "totalCreditsRequired": 0, - "concentrations": { - "minOptions": 0, - "maxOptions": 0, - "concentrationOptions": [] - }, - "nupaths": [], - "requirementGroups": [ - "IRequiredCourse", - "IAndCourse", - "IOrCourse", - "ICourseRange", - "IXofManyCourse", - "Section" - ], - "requirementGroupMap": { - "IRequiredCourse": { - "type": "AND", - "name": "IRequiredCourse", - "requirements": [ - { - "type": "COURSE", - "classId": 1800, - "subject": "CS" - } - ] - }, - "IAndCourse": { - "type": "AND", - "name": "IAndCourse", - "requirements": [ - { - "type": "AND", - "courses": [ - { - "type": "COURSE", - "classId": 1200, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 1210, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 1800, - "subject": "CS" - } - ] - } - ] - }, - "IOrCourse": { - "type": "AND", - "name": "IOrCourse", - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "COURSE", - "classId": 2500, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 2501, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 2510, - "subject": "CS" - } - ] - } - ] - }, - "ICourseRange": { - "type": "RANGE", - "name": "ICourseRange", - "numCreditsMin": 8, - "numCreditsMax": 8, - "requirements": { - "type": "RANGE", - "creditsRequired": 8, - "ranges": [ - { - "subject": "CS", - "idRangeStart": 3000, - "idRangeEnd": 4999 - } - ] - } - }, - "IXofManyCourse": { - "type": "OR", - "name": "IXofManyCourse", - "numCreditsMin": 8, - "numCreditsMax": 8, - "requirements": [ - { - "type": "OR", - "courses": [ - { - "type": "COURSE", - "classId": 3500, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3650, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3700, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 3800, - "subject": "CS" - }, - { - "type": "COURSE", - "classId": 4400, - "subject": "CS" - } - ] - } - ] - }, - "Section": { - "type": "AND", - "name": "Section", - "requirements": [ - { - "type": "AND", - "name": "Subsection", - "requirements": [ - { - "type": "COURSE", - "classId": 1341, - "subject": "MATH" - }, - { - "type": "COURSE", - "classId": 1342, - "subject": "MATH" - } - ] - } - ] - } - } -} diff --git a/apps/searchneu/lib/graduate/validation-worker/test-utils.ts b/apps/searchneu/lib/graduate/validation-worker/test-utils.ts deleted file mode 100644 index 32c677e0..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/test-utils.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - Concentrations, - IAndCourse, - ICourseRange, - IOrCourse, - IRequiredCourse, - IXofManyCourse, - type Major, - Requirement, - AuditCourse, - Section, -} from "../types"; -import { assertUnreachable, courseToString } from "./course-utils"; -import { MajorValidationTracker } from "./major-validation"; - -export type TestCourse = IRequiredCourse & { credits: number }; - -// Creates a test course with optional credit hours -export const course = ( - subject: string, - classId: number, - credits?: number, -): TestCourse => ({ - subject, - type: "COURSE", - classId: classId, - credits: credits ?? 4, -}); - -// Converts a TestCourse to a AuditCourse -export function convert(c: TestCourse): AuditCourse { - return { - ...c, - classId: String(c.classId), - name: courseToString(c), - numCreditsMax: c.credits, - numCreditsMin: c.credits, - id: null, - }; -} - -// Creates an OR requirement -export function or(...courses: Requirement[]): IOrCourse { - return { - type: "OR", - courses, - }; -} - -// Creates an AND requirement -export function and(...courses: Requirement[]): IAndCourse { - return { - type: "AND", - courses, - }; -} - -// Creates a RANGE requirement -export function range( - creditsRequired: number, - subject: string, - idRangeStart: number, - idRangeEnd: number, - exceptions: IRequiredCourse[], -): ICourseRange { - return { - type: "RANGE", - subject, - idRangeStart, - idRangeEnd, - exceptions, - }; -} - -// Creates an XOM (X of Many) requirement -export function xom( - numCreditsMin: number, - courses: Requirement[], -): IXofManyCourse { - return { - type: "XOM", - numCreditsMin, - courses, - }; -} - -// Creates a Section requirement -export function section( - title: string, - minRequirementCount: number, - requirements: Requirement[], -): { type: "SECTION" } & Section { - return { - title, - requirements, - minRequirementCount, - type: "SECTION", - }; -} - -// creates a concentration object -export function concentrations( - minOptions: number, - ...concentrationOptions: Section[] -): Concentrations { - return { - minOptions, - concentrationOptions, - }; -} - -// creates solution -export function solution(...sol: (string | TestCourse)[]) { - const credits = sol - .map((c) => (typeof c === "string" ? 4 : c.credits)) - .reduce((total, c) => total + c, 0); - return { - minCredits: credits, - maxCredits: credits, - sol: sol.map((s) => (typeof s === "string" ? s : courseToString(s))), - }; -} - -// makes a MajorValidationTracker with the test courses -export function makeTracker(...courses: TestCourse[]) { - return new MajorValidationTracker(courses.map(convert)); -} - -// converts old major to Major -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function convertToMajor(old: any): Major { - return { - name: old.name, - totalCreditsRequired: old.totalCreditsRequired, - yearVersion: old.yearVersion, - requirementSections: Object.values(old.requirementGroupMap).map( - convertToSection, - ), - concentrations: { - minOptions: old.concentrations.minOptions, - concentrationOptions: old.concentrations.concentrationOptions.map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c: any) => ({ - type: "SECTION", - title: c.name, - minRequirementCount: c.requirementGroups.length, - requirements: Object.values(c.requirementGroupMap).map( - convertToSection, - ), - }), - ), - }, - }; -} - -// converts old requirement to section -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function convertToSection(r: any): Section { - switch (r.type) { - case "AND": - return { - type: "SECTION", - minRequirementCount: r.requirements.length, - requirements: r.requirements.map(convertToRequirement), - title: r.name, - }; - case "OR": - return { - type: "SECTION", - title: r.name, - minRequirementCount: 1, - requirements: [ - { - type: "XOM", - numCreditsMin: r.numCreditsMin, - courses: r.requirements.map(convertToRequirement), - }, - ], - }; - case "RANGE": - return { - type: "SECTION", - title: r.name, - minRequirementCount: 1, - requirements: [convertToRequirement(r.requirements)], - }; - default: - return assertUnreachable(); - } -} - -// converts old requirement format to Requirement -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function convertToRequirement(r: any): Requirement { - switch (r.type) { - case "OR": - return { - type: "OR", - courses: r.courses.map(convertToRequirement), - }; - case "AND": - return { - type: "AND", - courses: r.courses.map(convertToRequirement), - }; - case "RANGE": - return { - type: "XOM", - numCreditsMin: r.creditsRequired, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - courses: r.ranges.map((r: any) => ({ - type: "RANGE", - exceptions: [], - idRangeStart: r.idRangeStart, - idRangeEnd: r.idRangeEnd, - subject: r.subject, - })), - }; - case "COURSE": - return r; - case "CREDITS": - return { - type: "XOM", - numCreditsMin: r.minCredits, - courses: r.courses.map(convertToRequirement), - }; - default: - return assertUnreachable(); - } -} diff --git a/apps/searchneu/lib/graduate/validation-worker/unit-validation.test.ts b/apps/searchneu/lib/graduate/validation-worker/unit-validation.test.ts deleted file mode 100644 index 6b16cfbf..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/unit-validation.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { test, describe } from "node:test"; -import assert from "node:assert"; -import { - makeTracker, - range, - solution, - xom, - or, - and, - section, -} from "./test-utils"; -import { validateRequirement } from "./major-validation"; -import * as c from "./mock-courses"; -import { Ok } from "../types"; - -describe("validate range requirement", () => { - test("range requirement pass", () => { - // Create a range requirement: need 8 credits from CS courses numbered 2000-3000 - const rangeRequirement = range(8, "CS", 2000, 3000, []); - - // took CS2800, CS2810 (both in range) - const tracker = makeTracker(c.cs2800, c.cs2810, c.ds3000, c.cs3500); - - // Should return Ok with solutions containing only CS2800 and CS2810 - assert.deepStrictEqual( - validateRequirement(rangeRequirement, tracker), - Ok([ - solution(c.cs2800), - solution("CS2800", "CS2810"), - solution(c.cs2810), - ]), - ); - }); - - test("range requirement filters out of range courses", () => { - // Range is CS 2000-4000, but CS2500 is excluded - const rangeRequirement = range(8, "CS", 2000, 4000, [c.cs2500]); - - // took CS3500 (in range) - const tracker = makeTracker(c.cs1200, c.cs4300, c.cs3500); - - // Only CS3500 should be included - assert.deepStrictEqual( - validateRequirement(rangeRequirement, tracker), - Ok([solution(c.cs3500)]), - ); - }); -}); - -describe("validate XOM with range requirement", () => { - test("XOM with range - sufficient credits", () => { - const rangeRequirement = range(8, "CS", 2000, 3000, []); - const xomRequirement = xom(8, [rangeRequirement]); - - // took CS2800 and CS2810 - const tracker = makeTracker(c.cs2800, c.cs2810, c.ds3000, c.cs3500); - - const result = validateRequirement(xomRequirement, tracker); - - // Should succeed - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - // Verify at least one solution has >= 8 credits - const hasValidSolution = result.ok.some((sol) => sol.minCredits >= 8); - assert.strictEqual(hasValidSolution, true); - } - }); - - test("XOM with range - insufficient credits", () => { - // range is: CS 2000-3000 courses - const rangeRequirement = range(8, "CS", 2000, 3000, []); - const xomRequirement = xom(12, [rangeRequirement]); - - // only took CS2800, which is not enough - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(xomRequirement, tracker); - - // fails because we need 12 credits but only have 4 - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "XOM"); - assert.strictEqual(result.err.minRequiredCredits, 12); - assert.strictEqual(result.err.maxPossibleCredits, 4); // Only achieved 4 credits - } - }); -}); - -describe("validate all error types", () => { - test("CourseError - required course not taken", () => { - // Student took CS2800 and CS3500, but not CS2500 - const tracker = makeTracker(c.cs2800, c.cs3500); - - // Try to validate that CS2500 was taken - const result = validateRequirement(c.cs2500, tracker); - - // Should fail with CourseError because CS2500 is missing - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "COURSE"); - assert.strictEqual(result.err.requiredCourse, "CS2500"); - } - }); - - test("AndError - unsatisfied child requirement", () => { - // AND requires BOTH CS2500 AND CS2800 - const andRequirement = and(c.cs2500, c.cs2800); - - // Student only took CS2800 (missing CS2500) - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(andRequirement, tracker); - - // Should fail because one child (CS2500) is unsatisfied - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "AND"); - assert.ok(result.err.error.type === "AND_UNSAT_CHILD"); - } - }); - - test("AndError - no valid solution (courses conflict)", () => { - // AND requires: - // - Child 1: CS2800 OR CS2500 - // - Child 2: CS2800 OR CS3500 - const andRequirement = and(or(c.cs2800, c.cs2500), or(c.cs2800, c.cs3500)); - - // Student only took CS2800 once (but both children want to use it!) - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(andRequirement, tracker); - - // Should fail because there's no valid solution: - // - Both ORs can be satisfied individually - // - But we can't use CS2800 for BOTH at the same time (only took it once) - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "AND"); - assert.ok( - result.err.error.type === "AND_NO_SOLUTION" || - result.err.error.type === "AND_UNSAT_CHILD_AND_NO_SOLUTION", - ); - } - }); - - test("OrError - all options fail", () => { - // OR requires at least one of: CS2500, CS2800, or CS3500 - const orRequirement = or(c.cs2500, c.cs2800, c.cs3500); - - // Student took CS4500 instead (doesn't match any option) - const tracker = makeTracker(c.cs4500); - - const result = validateRequirement(orRequirement, tracker); - - // Should fail because ALL three options failed - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "OR"); - assert.strictEqual(result.err.childErrors.length, 3); // All 3 options failed - } - }); - - test("XOMError - insufficient credits", () => { - // XOM requires 12 credits from CS 2000-4000 range - const xomRequirement = xom(12, [range(12, "CS", 2000, 4000, [])]); - - // Student only took CS2800 (4 credits) - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(xomRequirement, tracker); - - // Should fail because we need 12 credits but only have 4 - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "XOM"); - assert.strictEqual(result.err.minRequiredCredits, 12); // Need this much - assert.strictEqual(result.err.maxPossibleCredits, 4); // Only have this much - } - }); - - test("SectionError - insufficient requirements satisfied", () => { - // Section requires 3 out of 4 courses to be taken - const sectionRequirement = section( - "Core Requirements", - 3, // Need 3 out of 4 - [c.cs2500, c.cs2800, c.cs3500, c.cs4500], - ); - - // Student only took CS2800 (1 out of 4) - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(sectionRequirement, tracker); - - // Should fail because only 1 requirement satisfied, but need 3 - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "SECTION"); - assert.strictEqual(result.err.sectionTitle, "Core Requirements"); - assert.strictEqual(result.err.minRequiredChildCount, 3); // Need 3 - assert.strictEqual(result.err.maxPossibleChildCount, 1); // Only got 1 - } - }); - - test("SectionError - no requirements satisfied", () => { - // Section requires 2 out of 3 courses - const sectionRequirement = section( - "Electives", - 2, // Need 2 out of 3 - [c.cs2500, c.cs3500, c.cs4500], - ); - - // took CS2800 (not in the section at all) - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(sectionRequirement, tracker); - - // fails because 0 requirements satisfied, but need 2 - assert.strictEqual(result.type, "Err"); - if (result.type === "Err") { - assert.strictEqual(result.err.type, "SECTION"); - assert.strictEqual(result.err.minRequiredChildCount, 2); // Need 2 - assert.strictEqual(result.err.maxPossibleChildCount, 0); // Got 0 - } - }); -}); - -describe("validate successful requirements", () => { - test("CourseRequirement - course taken successfully", () => { - // took CS2500, CS2800, CS3500 - const tracker = makeTracker(c.cs2500, c.cs2800, c.cs3500); - - // check that CS2500 was taken - const result = validateRequirement(c.cs2500, tracker); - - // Should pass with solution containing CS2500 - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - assert.deepStrictEqual(result.ok, [solution(c.cs2500)]); - } - }); - - test("AndRequirement - all children satisfied", () => { - // AND requires CS2500 and CS2800 - const andRequirement = and(c.cs2500, c.cs2800); - - // Student took both courses - const tracker = makeTracker(c.cs2500, c.cs2800); - - const result = validateRequirement(andRequirement, tracker); - - // Should succeed with solution containing both courses - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - assert.deepStrictEqual(result.ok, [solution(c.cs2500, c.cs2800)]); - } - }); - - test("AndRequirement - multiple valid solutions", () => { - // AND requires: - // - Child 1: CS2800 OR CS2500 - // - Child 2: CS3500 OR CS4500 - const andRequirement = and(or(c.cs2800, c.cs2500), or(c.cs3500, c.cs4500)); - - // Student took all four courses - const tracker = makeTracker(c.cs2800, c.cs2500, c.cs3500, c.cs4500); - - const result = validateRequirement(andRequirement, tracker); - - // Should succeed with multiple valid combinations - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - // Should have 4 solutions: (CS2800,CS3500), (CS2800,CS4500), (CS2500,CS3500), (CS2500,CS4500) - assert.strictEqual(result.ok.length, 4); - } - }); - - test("OrRequirement - one option satisfied", () => { - // OR requires at least CS2500, CS2800, or CS3500 - const orRequirement = or(c.cs2500, c.cs2800, c.cs3500); - - // Student took CS2800 - const tracker = makeTracker(c.cs2800); - - const result = validateRequirement(orRequirement, tracker); - - // Should succeed with CS2800 as solution - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - assert.deepStrictEqual(result.ok, [solution(c.cs2800)]); - } - }); - - test("OrRequirement - multiple options satisfied", () => { - // OR requires at least one of: CS2500, CS2800, or CS3500 - const orRequirement = or(c.cs2500, c.cs2800, c.cs3500); - - // Student took all three (all options satisfied) - const tracker = makeTracker(c.cs2500, c.cs2800, c.cs3500); - - const result = validateRequirement(orRequirement, tracker); - - // Should succeed with all three as separate solutions - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - assert.strictEqual(result.ok.length, 3); // Three valid solutions - } - }); - - test("SectionRequirement - minimum requirements met", () => { - // Section requires 2 out of 3 courses - const sectionRequirement = section( - "Electives", - 2, // Need 2 out of 3 - [c.cs2500, c.cs3500, c.cs4500], - ); - - // Student took exactly 2 courses (CS2500 and CS3500) - const tracker = makeTracker(c.cs2500, c.cs3500); - - const result = validateRequirement(sectionRequirement, tracker); - - // Should succeed because 2 requirements are satisfied - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - // At least one solution should exist - assert.ok(result.ok.length > 0); - } - }); - - test("SectionRequirement - all requirements satisfied", () => { - // Section requires 2 out of 4 courses - const sectionRequirement = section( - "Core Requirements", - 2, // Need 2 out of 4 - [c.cs2500, c.cs2800, c.cs3500, c.cs4500], - ); - - // Student took all 4 courses (exceeds requirement) - const tracker = makeTracker(c.cs2500, c.cs2800, c.cs3500, c.cs4500); - - const result = validateRequirement(sectionRequirement, tracker); - - // Should succeed with multiple valid combinations - assert.strictEqual(result.type, "Ok"); - if (result.type === "Ok") { - // Should have multiple solutions (different pairs of courses) - assert.ok(result.ok.length > 0); - } - }); -}); diff --git a/apps/searchneu/lib/graduate/validation-worker/worker.ts b/apps/searchneu/lib/graduate/validation-worker/worker.ts deleted file mode 100644 index 16688cf5..00000000 --- a/apps/searchneu/lib/graduate/validation-worker/worker.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Major, Minor, AuditCourse } from "../types"; -import { - validateMajor, - MajorValidationResult, - MajorValidationInputError, -} from "./major-validation"; - -export enum WorkerMessageType { - Loaded = "Loaded", - ValidationResult = "ValidationResult", - ValidationError = "ValidationError", -} - -export type WorkerMessage = Loaded | ValidationResult | ValidationError; - -type ValidationResult = { - type: WorkerMessageType.ValidationResult; - result: MajorValidationResult; - requestNumber: number; -}; - -type ValidationError = { - type: WorkerMessageType.ValidationError; - error: { - name: string; - message: string; - field?: string; - receivedValue?: unknown; - }; - requestNumber: number; -}; - -type Loaded = { type: WorkerMessageType.Loaded }; - -export interface WorkerPostInfo { - major: Major; - minor?: Minor; - taken: AuditCourse[]; - concentration?: string; - requestNumber: number; -} - -// Let the host page know the worker is ready. -const loadMessage: Loaded = { type: WorkerMessageType.Loaded }; -postMessage(loadMessage); - -addEventListener("message", ({ data }: MessageEvent) => { - try { - const validationResult: ValidationResult = { - type: WorkerMessageType.ValidationResult, - result: validateMajor( - data.major, - data.taken, - data.minor, - data.concentration, - ), - requestNumber: data.requestNumber, - }; - - postMessage(validationResult); - } catch (error) { - const errorMessage: ValidationError = { - type: WorkerMessageType.ValidationError, - error: - error instanceof MajorValidationInputError - ? { - name: error.name, - message: error.message, - field: error.field, - receivedValue: error.receivedValue, - } - : { - name: error instanceof Error ? error.name : "UnknownError", - message: - error instanceof Error - ? error.message - : "An unknown error occurred", - }, - requestNumber: data.requestNumber, - }; - - postMessage(errorMessage); - } -});