diff --git a/apps/web/lib/coursemap/requisite-evaluation.ts b/apps/web/lib/coursemap/requisite-evaluation.ts new file mode 100644 index 00000000..f1cbeb06 --- /dev/null +++ b/apps/web/lib/coursemap/requisite-evaluation.ts @@ -0,0 +1,290 @@ +import { gradePointAverage, weightedAverageMark } from "@/lib/academic/metrics"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import type { CompletedRequisiteCourse } from "@/lib/coursemap/requisite-summary"; +import type { CourseRuleCondition } from "@/lib/coursemap/requisite-tree"; +import type { Attempt } from "@/lib/coursemap/types"; + +/** + * What a student has done, in the terms a requisite rule asks about. Every + * field a rule cannot be judged without is nullable, so a missing record reads + * as "not known" rather than as a failure. + */ +export type StudentRecord = { + completed: ReadonlyMap< + string, + { units: number; mark: number | null; tags?: readonly string[] } + >; + /** Courses taken this semester, which satisfy a concurrent requisite. */ + enrolled: ReadonlySet; + programmeCodes: readonly string[]; + wam: number | null; + gpa: number | null; + studyYear: number | null; +}; + +export type RequisiteStatus = "met" | "partial" | "unmet" | "unknown"; + +export type RequisiteMeasure = + | { kind: "units"; value: number; target: number } + | { kind: "score"; value: number; threshold: number; scale: "wam" | "gpa" } + | { kind: "count"; value: number; target: number }; + +export type ConditionEvaluation = { + status: RequisiteStatus; + measure?: RequisiteMeasure; + /** One line about the student's side of the condition. */ + detail?: string; +}; + +const COURSE_CODE = /^([A-Z]{4})(\d)\d{3}[A-Z]?$/u; + +function courseLevel(code: string) { + const match = COURSE_CODE.exec(code); + return match ? Number(match[2]) * 1000 : null; +} + +function subjectOf(code: string) { + return COURSE_CODE.exec(code)?.[1] ?? null; +} + +function unitsWhere( + student: StudentRecord, + include: (code: string, tags: readonly string[]) => boolean, +) { + let units = 0; + for (const [code, result] of student.completed) { + if (include(code, result.tags ?? [])) units += result.units; + } + return units; +} + +function unitsStatus(value: number, target: number): RequisiteStatus { + if (value >= target) return "met"; + return value > 0 ? "partial" : "unmet"; +} + +function unitsEvaluation(value: number, target: number): ConditionEvaluation { + return { + status: unitsStatus(value, target), + measure: { kind: "units", value, target }, + }; +} + +function scoreEvaluation( + value: number | null, + threshold: number, + scale: "wam" | "gpa", +): ConditionEvaluation { + if (value === null) return { status: "unknown" }; + return { + status: value >= threshold ? "met" : "unmet", + measure: { kind: "score", value, threshold, scale }, + }; +} + +/** + * One condition against one student. Conditions the record cannot answer, + * such as a permission or a tag the catalogue does not carry, stay unknown. + */ +export function evaluateCondition( + condition: CourseRuleCondition, + student: StudentRecord, +): ConditionEvaluation { + switch (condition.kind) { + case "course": { + const result = student.completed.get(condition.code); + if (result) { + if (condition.minimumMark === null) return { status: "met" }; + if (result.mark === null) + return { status: "unknown", detail: "Your mark is not recorded" }; + return { + status: result.mark >= condition.minimumMark ? "met" : "unmet", + detail: `You got ${result.mark} in ${condition.code}`, + }; + } + if ( + condition.requirementMode === "completed_or_concurrent" && + student.enrolled.has(condition.code) + ) { + return { + status: "met", + detail: "Enrolled this semester, which counts for this course", + }; + } + return { status: "unmet" }; + } + case "incompatible": + return student.completed.has(condition.code) + ? { + status: "unmet", + detail: `You have completed ${condition.code}`, + } + : { + status: "met", + detail: `You haven't completed ${condition.code}`, + }; + case "units_total": + return unitsEvaluation( + unitsWhere(student, () => true), + condition.units, + ); + case "subject_units": + return condition.subject + ? unitsEvaluation( + unitsWhere( + student, + (code) => subjectOf(code) === condition.subject, + ), + condition.units, + ) + : { status: "unknown" }; + case "level_units": + return unitsEvaluation( + unitsWhere(student, (code) => { + const level = courseLevel(code); + if (level === null || level < condition.minimumLevel) return false; + if (condition.maximumLevel !== null && level > condition.maximumLevel) + return false; + return !condition.subject || subjectOf(code) === condition.subject; + }), + condition.units, + ); + case "course_set_units": + return unitsEvaluation( + unitsWhere(student, (code) => condition.courseCodes.includes(code)), + condition.units, + ); + case "year_standing": + if (student.studyYear === null) return { status: "unknown" }; + return { + status: student.studyYear >= condition.minimumYear ? "met" : "unmet", + detail: `You're in year ${student.studyYear}`, + }; + case "structure": + if (!condition.structureCode) return { status: "unknown" }; + return student.programmeCodes.includes(condition.structureCode) + ? { + status: "met", + detail: `You're enrolled in ${condition.structureCode}`, + } + : { status: "unmet" }; + case "structure_set": { + const matched = condition.structureCodes.filter((code) => + student.programmeCodes.includes(code), + ).length; + const needed = condition.minimumCount ?? 1; + return { status: matched >= needed ? "met" : "unmet" }; + } + case "wam": + return scoreEvaluation(student.wam, condition.minimumWam, "wam"); + case "gpa": + return scoreEvaluation(student.gpa, condition.minimumGpa, "gpa"); + case "tagged_units": { + // A tag is one category however it is capitalised. + const wanted = condition.tag.toLowerCase(); + return unitsEvaluation( + unitsWhere(student, (_code, tags) => + tags.some((tag) => tag.toLowerCase() === wanted), + ), + condition.units, + ); + } + case "elective_units": + case "permission": + case "other": + return { status: "unknown" }; + } +} + +type Group = Extract; + +export function groupRequiredCount(group: Group) { + if (group.operator === "all_of") return group.conditions.length; + if (group.operator === "any_of") return 1; + return group.minimumCount ?? 1; +} + +/** A rule or sub-rule against one student, with its children's results. */ +export function evaluateRule( + expression: CourseRuleExpression, + student: StudentRecord, +): ConditionEvaluation { + if (expression.kind !== "group") + return evaluateCondition(expression, student); + const results = expression.conditions.map((child) => + evaluateRule(child, student), + ); + const met = results.filter((result) => result.status === "met").length; + const needed = groupRequiredCount(expression); + const measure: RequisiteMeasure = { + kind: "count", + value: Math.min(met, needed), + target: needed, + }; + if (met >= needed) return { status: "met", measure }; + if (results.every((result) => result.status === "unknown")) + return { status: "unknown" }; + const started = results.some( + (result) => result.status === "met" || result.status === "partial", + ); + return { status: started ? "partial" : "unmet", measure }; +} + +/** + * The reader's record in the terms a requisite asks about. Completion comes + * from the server snapshot, which only counts finished attempts; marks and + * this semester's enrolments come from the plan. + */ +export function studentRecord({ + attempts, + commencementYear, + completedCourses, + programmeCodes, +}: { + attempts: readonly Attempt[]; + commencementYear: number | null; + completedCourses: readonly CompletedRequisiteCourse[]; + programmeCodes: readonly string[]; +}): StudentRecord { + const finished = attempts.filter((attempt) => attempt.status === "completed"); + const markByCode = new Map( + finished.flatMap((attempt) => + attempt.mark === undefined + ? [] + : [[attempt.courseCode, attempt.mark] as const], + ), + ); + const completed = new Map( + completedCourses.map((course) => [ + course.code.toUpperCase(), + { + units: course.units, + mark: markByCode.get(course.code.toUpperCase()) ?? null, + tags: course.tags ?? [], + }, + ]), + ); + const results = finished.map((attempt) => ({ + mark: attempt.mark, + resultCode: attempt.resultCode, + units: + attempt.unitsEarned ?? + attempt.unitsAttempted ?? + completed.get(attempt.courseCode)?.units ?? + 0, + })); + return { + completed, + enrolled: new Set( + attempts + .filter((attempt) => attempt.status === "enrolled") + .map((attempt) => attempt.courseCode), + ), + programmeCodes: programmeCodes.map((code) => code.toUpperCase()), + wam: weightedAverageMark(results), + gpa: gradePointAverage(results), + studyYear: commencementYear + ? Math.max(1, new Date().getFullYear() - commencementYear + 1) + : null, + }; +} diff --git a/apps/web/lib/coursemap/requisite-progress.ts b/apps/web/lib/coursemap/requisite-progress.ts index b1bf31cb..835793b3 100644 --- a/apps/web/lib/coursemap/requisite-progress.ts +++ b/apps/web/lib/coursemap/requisite-progress.ts @@ -83,12 +83,33 @@ export async function loadCurrentUserRequisiteCompletion(): Promise(); + for (const row of tagRows ?? []) { + tagsByVersionId.set(row.version_id, [ + ...(tagsByVersionId.get(row.version_id) ?? []), + row.name, + ]); + } return { completedCourses: attemptRows.flatMap((attempt) => { const codeId = codeIdByVersionId.get(attempt.catalogue_version_id); const code = codeId ? codeByCourseId.get(codeId) : undefined; return code && attempt.units_earned > 0 - ? [{ code, units: attempt.units_earned }] + ? [ + { + code, + units: attempt.units_earned, + tags: tagsByVersionId.get(attempt.catalogue_version_id) ?? [], + }, + ] : []; }), enrolledProgrammeCodes: await loadEnrolledProgrammeCodes( diff --git a/apps/web/lib/coursemap/requisite-samples.ts b/apps/web/lib/coursemap/requisite-samples.ts new file mode 100644 index 00000000..759ae0a5 --- /dev/null +++ b/apps/web/lib/coursemap/requisite-samples.ts @@ -0,0 +1,168 @@ +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import type { StudentRecord } from "@/lib/coursemap/requisite-evaluation"; + +export type SampleStudent = "new" | "partway" | "complete"; + +type Draft = { + completed: Map< + string, + { units: number; mark: number | null; tags?: string[] } + >; + programmeCodes: Set; + wam: number | null; + gpa: number | null; + studyYear: number; +}; + +const COURSE_UNITS = 6; + +/** + * Invented course codes that count towards a unit rule. They use the top of + * each level's number range and skip any code the sample already holds, so + * a course the rule names is never counted twice. + */ +function fillerCodes( + subject: string, + level: number, + count: number, + used: Map, +) { + const codes: string[] = []; + for (let serial = 999; codes.length < count && serial >= 900; serial -= 1) { + const code = `${subject}${level}${String(serial).padStart(3, "0")}`; + if (!used.has(code)) codes.push(code); + } + return codes; +} + +function addUnits( + draft: Draft, + units: number, + subject: string, + level: number, + tags: string[] = [], +) { + const count = Math.ceil(units / COURSE_UNITS); + for (const code of fillerCodes(subject, level, count, draft.completed)) { + draft.completed.set(code, { units: COURSE_UNITS, mark: 75, tags }); + } +} + +/** + * Satisfies a rule completely, or about half of it, so a reviewer can see + * every state a student might be in without touching anyone's record. + */ +function satisfy(node: CourseRuleExpression, draft: Draft, share: number) { + const partial = share < 1; + switch (node.kind) { + case "group": { + const needed = + node.operator === "all_of" + ? node.conditions.length + : node.operator === "any_of" + ? 1 + : (node.minimumCount ?? 1); + const target = partial ? Math.floor(needed / 2) : needed; + node.conditions + .slice(0, node.operator === "all_of" ? undefined : target) + .forEach((child, index) => { + if (node.operator === "all_of") { + satisfy(child, draft, partial && index % 2 === 1 ? 0 : share); + } else { + satisfy(child, draft, 1); + } + }); + return; + } + case "course": + if (share === 0) return; + draft.completed.set(node.code, { + units: COURSE_UNITS, + mark: Math.max(node.minimumMark ?? 0, 75), + }); + return; + case "units_total": + addUnits(draft, Math.floor(node.units * share), "ZZSA", 1); + return; + case "subject_units": + addUnits( + draft, + Math.floor(node.units * share), + node.subject ?? "ZZSA", + 1, + ); + return; + case "level_units": + addUnits( + draft, + Math.floor(node.units * share), + node.subject ?? "ZZSA", + node.minimumLevel / 1000, + ); + return; + case "course_set_units": { + const count = Math.ceil((node.units * share) / COURSE_UNITS); + for (const code of node.courseCodes.slice(0, count)) { + draft.completed.set(code, { units: COURSE_UNITS, mark: 75 }); + } + return; + } + case "year_standing": + draft.studyYear = Math.max( + draft.studyYear, + partial ? Math.max(1, node.minimumYear - 1) : node.minimumYear, + ); + return; + case "structure": + if (!partial && node.structureCode) + draft.programmeCodes.add(node.structureCode); + return; + case "structure_set": + if (!partial && node.structureCodes[0]) + draft.programmeCodes.add(node.structureCodes[0]); + return; + case "wam": + draft.wam = partial + ? node.minimumWam - 3 + : Math.max(node.minimumWam + 5, 75); + return; + case "gpa": + draft.gpa = partial + ? Math.max(0, node.minimumGpa - 0.5) + : Math.min(7, node.minimumGpa + 0.8); + return; + case "tagged_units": + addUnits(draft, Math.floor(node.units * share), "ZZTG", 1, [node.tag]); + return; + case "incompatible": + case "elective_units": + case "permission": + case "other": + return; + } +} + +/** A made-up student for previewing how a course's requisites read. */ +export function sampleStudent( + expression: CourseRuleExpression | null, + sample: SampleStudent, +): StudentRecord { + const draft: Draft = { + completed: new Map(), + programmeCodes: new Set(), + wam: null, + gpa: null, + studyYear: 1, + }; + if (expression && sample !== "new") { + satisfy(expression, draft, sample === "complete" ? 1 : 0.5); + } + return { + completed: draft.completed, + enrolled: new Set(), + programmeCodes: [...draft.programmeCodes], + wam: draft.wam, + gpa: draft.gpa, + studyYear: draft.studyYear, + }; +} diff --git a/apps/web/lib/coursemap/requisite-summary.ts b/apps/web/lib/coursemap/requisite-summary.ts index d49b9bd3..00d1901d 100644 --- a/apps/web/lib/coursemap/requisite-summary.ts +++ b/apps/web/lib/coursemap/requisite-summary.ts @@ -35,153 +35,6 @@ export type RequisiteExpression = export type CompletedRequisiteCourse = { code: string; units: number; + /** The tags on the version the student completed. */ + tags?: string[]; }; - -export type RequisiteProgress = - | { - kind: "course"; - code: string; - satisfied: boolean; - } - | { - completedUnits: number; - kind: "subject_units"; - requiredUnits: number; - satisfied: boolean; - subject: string; - } - | { - completedUnits: number; - kind: "level_units"; - level: number; - requiredUnits: number; - satisfied: boolean; - subject?: string; - } - | { - completedUnits: number; - kind: "units_total"; - requiredUnits: number; - satisfied: boolean; - } - | { - code: string; - kind: "programme_enrolment"; - name: string; - satisfied: boolean; - } - | { - conditions: RequisiteProgress[]; - kind: "group"; - operator: "all_of" | "any_of"; - satisfied: boolean; - }; - -const COURSE_LEVEL_PATTERN = /^[A-Z]{4}(\d)\d{3}[A-Z]?$/u; - -function courseLevel(code: string) { - const digit = COURSE_LEVEL_PATTERN.exec(code.toUpperCase())?.[1]; - return digit === undefined ? null : Number(digit) * 1000; -} - -/** - * Evaluates only completed course attempts. Planned and enrolled courses are - * deliberately excluded because the ANU wording requires completed study. - */ -export function evaluateRequisiteExpression( - expression: RequisiteExpression, - completedCourses: readonly CompletedRequisiteCourse[], - enrolledProgrammeCodes: readonly string[] = [], -): RequisiteProgress { - if (expression.kind === "programme_enrolment") { - return { - kind: "programme_enrolment", - code: expression.code, - name: expression.name, - satisfied: enrolledProgrammeCodes.some( - (code) => code.toUpperCase() === expression.code, - ), - }; - } - - if (expression.kind === "course") { - return { - kind: "course", - code: expression.code, - satisfied: completedCourses.some( - (course) => course.code.toUpperCase() === expression.code, - ), - }; - } - - if (expression.kind === "subject_units") { - const completedUnits = completedCourses.reduce((total, course) => { - const subject = course.code.slice(0, 4).toUpperCase(); - return subject === expression.subject && Number.isFinite(course.units) - ? total + course.units - : total; - }, 0); - return { - kind: "subject_units", - subject: expression.subject, - requiredUnits: expression.units, - completedUnits, - satisfied: completedUnits >= expression.units, - }; - } - - if (expression.kind === "level_units") { - const completedUnits = completedCourses.reduce((total, course) => { - const subject = course.code.slice(0, 4).toUpperCase(); - const level = courseLevel(course.code); - const subjectMatches = - expression.subject === undefined || subject === expression.subject; - return subjectMatches && - level === expression.level && - Number.isFinite(course.units) - ? total + course.units - : total; - }, 0); - return { - kind: "level_units", - level: expression.level, - ...(expression.subject !== undefined - ? { subject: expression.subject } - : {}), - requiredUnits: expression.units, - completedUnits, - satisfied: completedUnits >= expression.units, - }; - } - - if (expression.kind === "units_total") { - const completedUnits = completedCourses.reduce( - (total, course) => - Number.isFinite(course.units) ? total + course.units : total, - 0, - ); - return { - kind: "units_total", - requiredUnits: expression.units, - completedUnits, - satisfied: completedUnits >= expression.units, - }; - } - - const conditions = expression.conditions.map((condition) => - evaluateRequisiteExpression( - condition, - completedCourses, - enrolledProgrammeCodes, - ), - ); - return { - kind: "group", - operator: expression.operator, - conditions, - satisfied: - expression.operator === "all_of" - ? conditions.every((condition) => condition.satisfied) - : conditions.some((condition) => condition.satisfied), - }; -} diff --git a/apps/web/lib/coursemap/requisite-tree.ts b/apps/web/lib/coursemap/requisite-tree.ts index 8cda04fa..c61d0d38 100644 --- a/apps/web/lib/coursemap/requisite-tree.ts +++ b/apps/web/lib/coursemap/requisite-tree.ts @@ -1,352 +1,6 @@ -import type { - CoursePrerequisiteEdge, - CourseRuleExpression, -} from "@/lib/coursemap/course-types"; -import type { - RequirementTreeCondition, - RequirementTreeOption, -} from "@/lib/coursemap/requirement-tree-node"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; export type CourseRuleCondition = Exclude< CourseRuleExpression, { kind: "group" } >; - -/** - * One course rule condition in the shared requirement shape, so a requisite - * reads through the same vocabulary as a programme requirement instead of - * falling back to the ANU prose for every kind the narrow summary never - * covered. - */ -export function requisiteConditionNode( - condition: CourseRuleCondition, - position = 0, -): RequirementTreeCondition { - const options: RequirementTreeOption[] = - condition.kind === "course_set_units" - ? condition.courseCodes.map((code, index) => ({ - code, - kind: "course", - position: index, - structureKind: null, - })) - : condition.kind === "structure_set" - ? condition.structureCodes.map((code, index) => ({ - code, - kind: "structure", - position: index, - structureKind: condition.structureKind, - })) - : []; - return { - type: "condition", - conditionKind: condition.kind, - freeText: - condition.kind === "permission" || - condition.kind === "other" || - condition.kind === "structure" - ? condition.text - : null, - id: position, - itemCode: - condition.kind === "course" || condition.kind === "incompatible" - ? condition.code - : condition.kind === "structure" - ? condition.structureCode - : null, - maximumLevel: - condition.kind === "level_units" ? condition.maximumLevel : null, - maximumUnits: null, - minimumCourses: - condition.kind === "structure_set" ? condition.minimumCount : null, - minimumGpa: condition.kind === "gpa" ? condition.minimumGpa : null, - minimumLevel: - condition.kind === "level_units" ? condition.minimumLevel : null, - minimumMark: condition.kind === "course" ? condition.minimumMark : null, - minimumUnits: - condition.kind === "units_total" || - condition.kind === "subject_units" || - condition.kind === "level_units" || - condition.kind === "course_set_units" || - condition.kind === "tagged_units" || - condition.kind === "elective_units" - ? condition.units - : null, - minimumWam: condition.kind === "wam" ? condition.minimumWam : null, - minimumYear: - condition.kind === "year_standing" ? condition.minimumYear : null, - options, - position, - projectionKey: `${condition.kind}-${position}`, - requirementMode: - condition.kind === "course" ? condition.requirementMode : null, - sourceLocator: "", - sourceText: condition.sourceText, - structureKind: - condition.kind === "structure_set" ? condition.structureKind : null, - subjectCode: - condition.kind === "units_total" || - condition.kind === "subject_units" || - condition.kind === "level_units" - ? condition.subject - : null, - tag: condition.kind === "tagged_units" ? condition.tag : null, - }; -} - -/** - * A prerequisite rule drawn as a layered graph. Nodes carry the condition they - * came from rather than finished wording, so the display kit in - * `ui/requirements/requirement-presentation.ts` keeps ownership of how every - * condition kind reads and the graph cannot grow a second vocabulary. - */ -export type RequisiteGraphNode = - | { id: string; depth: number; kind: "current"; code: string } - | { - id: string; - depth: number; - kind: "course"; - code: string; - isAvailable: boolean; - condition: CourseRuleCondition | null; - } - | { - id: string; - depth: number; - kind: "requirement"; - condition: CourseRuleCondition; - } - | { - id: string; - depth: number; - kind: "choice"; - minimumCount: number | null; - operator: "all_of" | "any_of" | "at_least"; - } - | { - id: string; - depth: number; - kind: "unlocked"; - code: string; - isAvailable: boolean; - }; - -export type RequisiteGraphEdge = { - from: string; - to: string; - /** Leaves an alternative group, so this is one of several ways to qualify. */ - alternative: boolean; -}; - -export type RequisiteGraph = { - edges: RequisiteGraphEdge[]; - /** - * Courses the prerequisite rule excludes. Collected so the caller can say so - * separately; an incompatibility is never drawn as something to complete. - */ - incompatibleCodes: string[]; - /** Furthest prerequisite column, counting left from the course itself. */ - maximumDepth: number; - nodes: RequisiteGraphNode[]; - /** Whether the upstream side came from the rule tree or there is none. */ - source: "none" | "rule"; -}; - -const GRAPH_COURSE_CODE = /^[A-Z]{4}\d{4}[A-Z]?$/u; - -function collectIncompatibleCodes( - expression: CourseRuleExpression, - codes: Set, -) { - if (expression.kind === "group") { - for (const child of expression.conditions) { - collectIncompatibleCodes(child, codes); - } - return; - } - if (expression.kind === "incompatible") codes.add(expression.code); -} - -/** Whether anything survives once incompatibilities are taken out. */ -function hasRequirementContent(expression: CourseRuleExpression): boolean { - if (expression.kind === "incompatible") return false; - if (expression.kind !== "group") return true; - return expression.conditions.some(hasRequirementContent); -} - -/** - * An `all_of` earns a node inside an alternative, where flattening would make - * "one of X, or both Y and Z" read as three equal choices, and at the root. - * - * The root used to be flattened too, on the reasoning that every edge into the - * course already means "and". Readers do not see it that way: several arrows - * converging on one course read as several ways in, and once one of those - * arrows leaves a "Choose one" node the rest are read as further choices. For - * COMP3600 that turned "24 units of COMP, and one of MATH or COMP1600" into - * three alternatives. An explicit node says the requirements are all needed. - * A group with a single child is noise either way. - */ -function groupNeedsNode( - expression: Extract, - childCount: number, - insideAlternative: boolean, - isRoot: boolean, -) { - if (childCount < 2) return false; - if (expression.operator === "all_of") return insideAlternative || isRoot; - return true; -} - -export function buildRequisiteGraph({ - availableCourseCodes, - code, - expression, - prerequisiteEdges, -}: { - availableCourseCodes: ReadonlySet; - code: string; - expression: CourseRuleExpression | null; - prerequisiteEdges: readonly CoursePrerequisiteEdge[]; -}): RequisiteGraph { - const nodes: RequisiteGraphNode[] = []; - const edges: RequisiteGraphEdge[] = []; - const incompatible = new Set(); - const currentId = "course"; - nodes.push({ id: currentId, depth: 0, kind: "current", code }); - - const courseNodeByCode = new Map(); - let counter = 0; - const nextId = (prefix: string) => `${prefix}-${(counter += 1)}`; - - const addCourseNode = ( - courseCode: string, - depth: number, - condition: CourseRuleCondition | null, - ) => { - const existing = courseNodeByCode.get(courseCode); - if (existing) return existing; - const id = nextId("course"); - courseNodeByCode.set(courseCode, id); - nodes.push({ - id, - depth, - kind: "course", - code: courseCode, - isAvailable: availableCourseCodes.has(courseCode), - condition, - }); - return id; - }; - - let source: RequisiteGraph["source"] = "none"; - - if (expression) { - collectIncompatibleCodes(expression, incompatible); - const attach = ( - child: CourseRuleExpression, - parentId: string, - depth: number, - alternative: boolean, - ) => { - if (!hasRequirementContent(child)) return; - if (child.kind === "group") { - const children = child.conditions.filter(hasRequirementContent); - if ( - !groupNeedsNode( - child, - children.length, - alternative, - parentId === currentId, - ) - ) { - // Flattened, so each child inherits the meaning of the edge above it. - for (const grandchild of children) { - attach(grandchild, parentId, depth, alternative); - } - return; - } - const id = nextId("choice"); - nodes.push({ - id, - depth, - kind: "choice", - minimumCount: child.minimumCount, - operator: child.operator, - }); - edges.push({ from: id, to: parentId, alternative }); - for (const grandchild of children) { - attach(grandchild, id, depth + 1, child.operator !== "all_of"); - } - return; - } - if (child.kind === "course" && GRAPH_COURSE_CODE.test(child.code)) { - const id = addCourseNode(child.code, depth, child); - edges.push({ from: id, to: parentId, alternative }); - return; - } - const id = nextId("requirement"); - nodes.push({ id, depth, kind: "requirement", condition: child }); - edges.push({ from: id, to: parentId, alternative }); - }; - attach(expression, currentId, 1, false); - if (nodes.length > 1) source = "rule"; - } - - // Chain further upstream from every course the rule names, so a prerequisite - // of a prerequisite stays visible. Those courses carry no operator here; the - // AND and OR shape of their own rules belongs on their own pages. - const incoming = new Map(); - for (const edge of prerequisiteEdges) { - if (edge.from === edge.to) continue; - incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge]); - } - const depthByNode = new Map(nodes.map((node) => [node.id, node.depth])); - const visitUpstream = (courseCode: string, path: ReadonlySet) => { - const targetId = courseNodeByCode.get(courseCode); - if (!targetId) return; - const targetDepth = depthByNode.get(targetId) ?? 1; - for (const edge of incoming.get(courseCode) ?? []) { - if (path.has(edge.from)) continue; - const existingId = courseNodeByCode.get(edge.from); - const id = existingId ?? addCourseNode(edge.from, targetDepth + 1, null); - const nextDepth = Math.max( - depthByNode.get(id) ?? targetDepth + 1, - targetDepth + 1, - ); - depthByNode.set(id, nextDepth); - const node = nodes.find((candidate) => candidate.id === id); - if (node) node.depth = nextDepth; - if (!edges.some((item) => item.from === id && item.to === targetId)) { - edges.push({ from: id, to: targetId, alternative: false }); - } - visitUpstream(edge.from, new Set([...path, edge.from])); - } - }; - for (const courseCode of [...courseNodeByCode.keys()]) { - visitUpstream(courseCode, new Set([code, courseCode])); - } - - for (const edge of prerequisiteEdges) { - if (edge.from !== code || edge.to === code) continue; - if (nodes.some((node) => node.kind === "unlocked" && node.code === edge.to)) - continue; - const id = nextId("unlocked"); - nodes.push({ - id, - depth: -1, - kind: "unlocked", - code: edge.to, - isAvailable: edge.toIsAvailable || availableCourseCodes.has(edge.to), - }); - edges.push({ from: currentId, to: id, alternative: false }); - } - - return { - edges, - incompatibleCodes: [...incompatible].sort(), - // Always keep one prerequisite column so its empty state has somewhere to sit. - maximumDepth: Math.max(1, ...nodes.map((node) => node.depth)), - nodes, - source, - }; -} diff --git a/apps/web/tests/enrolment-steps.test.tsx b/apps/web/tests/enrolment-steps.test.tsx new file mode 100644 index 00000000..e8bfbbc5 --- /dev/null +++ b/apps/web/tests/enrolment-steps.test.tsx @@ -0,0 +1,133 @@ +import { expect, test } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import type { StudentRecord } from "@/lib/coursemap/requisite-evaluation"; +import { EnrolmentSteps } from "@/ui/courses/enrolment-steps"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; +const course = (code: string): CourseRuleExpression => ({ + ...base, + kind: "course", + code, + minimumMark: null, + requirementMode: "completed", +}); + +const rule: CourseRuleExpression = { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "units_total", subject: null, units: 72 }, + { + kind: "group", + operator: "at_least", + minimumCount: 2, + conditions: [course("COMP2100"), course("COMP2120"), course("COMP2300")], + }, + { ...base, kind: "wam", minimumWam: 70 }, + { ...base, kind: "permission", text: "Apply to the School of Computing." }, + { ...base, kind: "incompatible", code: "COMP3530" }, + ], +}; + +const student: StudentRecord = { + completed: new Map([ + ["COMP2300", { units: 6, mark: 75 }], + ["COMP1100", { units: 54, mark: 70 }], + ]), + enrolled: new Set(), + programmeCodes: [], + wam: 72.5, + gpa: 5.8, + studyYear: 3, +}; + +function renderSteps(value: StudentRecord | null) { + return render( + , + ); +} + +test("every part of the rule is a step, with permission and exclusions last", () => { + renderSteps(null); + const steps = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(steps.map((step) => step.querySelector("p")?.textContent)).toEqual([ + "Complete 72 units", + "Complete any 2 of these courses", + "Have a WAM of 70 or more", + "Get permission to enrol", + "You can't take this if you've completed COMP3530", + ]); + expect(screen.getByText("Sign in to see your progress")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); +}); + +test("a signed-in student sees progress towards each step", () => { + renderSteps(student); + expect( + screen.getByRole("progressbar", { name: "Units completed" }), + ).toHaveAttribute("aria-valuenow", "60"); + expect(screen.getByText("1 of 2 done")).toBeInTheDocument(); + expect(screen.getByText("72.5")).toBeInTheDocument(); + expect( + screen.getByText("You haven't completed COMP3530"), + ).toBeInTheDocument(); + // Units, courses and the exclusion count; the permission cannot be checked. + expect(screen.getByText("2 of 4 met")).toBeInTheDocument(); +}); + +test("completed courses come first in a choice", () => { + renderSteps(student); + const chips = screen + .getAllByRole("link") + .map((link) => link.textContent?.replace(/\s*\(done\)/u, "")); + expect(chips).toEqual(["COMP2300", "COMP2100", "COMP2120"]); +}); + +test("a unit requirement links to the courses that count towards it", () => { + render( + , + ); + expect( + screen.getByRole("link", { name: /24 units of COMP courses/u }), + ).toHaveAttribute("href", "/courses?year=2026&subject=COMP"); + expect( + screen.getByRole("link", { name: /2000-level or higher COMP/u }), + ).toHaveAttribute("href", "/courses?year=2026&level=2%2B&subject=COMP"); + expect(screen.getByRole("link", { name: /tagged Science/u })).toHaveAttribute( + "href", + "/courses?year=2026&tag=Science", + ); +}); diff --git a/apps/web/tests/prereq-graph.test.tsx b/apps/web/tests/prereq-graph.test.tsx deleted file mode 100644 index 7c66c653..00000000 --- a/apps/web/tests/prereq-graph.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { expect, test } from "vitest"; -import { render, screen, within } from "@testing-library/react"; -import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; -import { PrereqGraph } from "@/ui/prereq-graph"; -import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; - -const base = { - confidence: 1, - hardness: "hard" as const, - reviewState: "automatic" as const, - sourceText: "", -}; - -/** COMP3600 in the local catalogue: 24 units of COMP AND (6 units of MATH OR COMP1600). */ -const comp3600Rule: CourseRuleExpression = { - kind: "group", - operator: "all_of", - minimumCount: null, - conditions: [ - { ...base, kind: "subject_units", subject: "COMP", units: 24 }, - { - kind: "group", - operator: "any_of", - minimumCount: null, - conditions: [ - { ...base, kind: "subject_units", subject: "MATH", units: 6 }, - { - ...base, - kind: "course", - code: "COMP1600", - minimumMark: null, - requirementMode: "completed", - }, - ], - }, - ], -}; - -function renderGraph(props: Partial[0]> = {}) { - return render( - - - , - ); -} - -test("every condition of the rule is drawn, including the unit requirements", () => { - renderGraph(); - // Each unit rule is one line that leads with the figure a student needs. - expect(screen.getByText("24 units of COMP courses")).toBeInTheDocument(); - expect(screen.getByText("6 units of MATH courses")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /COMP1600/u })).toHaveAttribute( - "href", - "/courses/2026/comp1600", - ); -}); - -test("alternatives are marked as a choice and the rule's AND is explicit", () => { - renderGraph(); - expect(screen.getByText("Choose one")).toBeInTheDocument(); - // The 24 units of COMP sit beside the choice, not inside it. The root AND - // used to be left for the reader to infer, and several arrows converging on - // the course read as several ways in, so a named junction now holds both. - expect(screen.getByText("All of these")).toBeInTheDocument(); -}); - -test("a nested all_of inside a choice keeps its own group node", () => { - renderGraph({ - expression: { - kind: "group", - operator: "any_of", - minimumCount: null, - conditions: [ - { - ...base, - kind: "course", - code: "COMP1600", - minimumMark: null, - requirementMode: "completed", - }, - { - kind: "group", - operator: "all_of", - minimumCount: null, - conditions: [ - { ...base, kind: "subject_units", subject: "MATH", units: 6 }, - { ...base, kind: "units_total", subject: null, units: 24 }, - ], - }, - ], - }, - }); - expect(screen.getByText("Choose one")).toBeInTheDocument(); - expect(screen.getByText("All of these")).toBeInTheDocument(); -}); - -test("an incompatibility is stated as an exclusion, never as a prerequisite", () => { - renderGraph({ - expression: { - kind: "group", - operator: "all_of", - minimumCount: null, - conditions: [ - { - ...base, - kind: "course", - code: "COMP1600", - minimumMark: null, - requirementMode: "completed", - }, - { ...base, kind: "incompatible", code: "COMP6466" }, - ], - }, - }); - expect( - screen.queryByRole("link", { name: /COMP6466/u }), - ).not.toBeInTheDocument(); - expect(screen.getByText(/Not a prerequisite.*COMP6466/u)).toBeInTheDocument(); -}); - -test("course state carries a word as well as a colour", () => { - renderGraph({ - expression: { - kind: "group", - operator: "any_of", - minimumCount: null, - conditions: [ - { - ...base, - kind: "course", - code: "COMP1600", - minimumMark: null, - requirementMode: "completed", - }, - { - ...base, - kind: "course", - code: "COMP1110", - minimumMark: null, - requirementMode: "completed", - }, - ], - }, - availableCourseCodes: new Set(["COMP3600", "COMP1600", "COMP1110"]), - showStudentState: true, - statusByCode: new Map([ - ["COMP1600", "completed"], - ["COMP1110", "planned"], - ] as const), - }); - expect( - within(screen.getByRole("link", { name: /COMP1600/u })).getByText( - "Completed", - ), - ).toBeInTheDocument(); - expect( - within(screen.getByRole("link", { name: /COMP1110/u })).getByText( - "Planned", - ), - ).toBeInTheDocument(); -}); - -test("unlocks says it is unknown rather than implying nothing follows", () => { - renderGraph({ unlocksAreKnown: false }); - expect(screen.getByText("Not known yet")).toBeInTheDocument(); - expect( - screen.queryByText("No published course lists this one"), - ).not.toBeInTheDocument(); -}); - -test("a known and empty reverse lookup says so plainly", () => { - renderGraph({ unlocksAreKnown: true }); - expect( - screen.getByText("No published course lists this one"), - ).toBeInTheDocument(); -}); - -test("unlocked courses appear when the reverse lookup found some", () => { - renderGraph({ - prerequisiteEdges: [ - { - from: "COMP3600", - to: "COMP4600", - fromIsAvailable: true, - toIsAvailable: true, - }, - ], - }); - expect(screen.getByRole("link", { name: /COMP4600/u })).toHaveAttribute( - "href", - "/courses/2026/comp4600", - ); -}); - -test("without a rule the graph draws no prerequisites of its own", () => { - renderGraph({ - expression: null, - prerequisiteEdges: [ - { - from: "COMP1600", - to: "COMP3600", - fromIsAvailable: true, - toIsAvailable: true, - }, - ], - }); - // Only the rule says what a course requires; a stored edge alone carries no - // operator, so nothing is drawn from it. - expect(screen.queryByRole("link", { name: /COMP1600/u })).toBeNull(); - expect( - screen.getByText( - "The prerequisites for COMP3600 have not been read into a chain yet. They are listed below as ANU publishes them.", - ), - ).toBeInTheDocument(); -}); - -test("a course with no rule and no references still explains the gap", () => { - renderGraph({ expression: null, hasPrerequisiteWording: false }); - // Nothing either side, so a sentence rather than three unconnected boxes. - expect( - screen.getByText( - "COMP3600 has no prerequisites, and no published course lists it as one.", - ), - ).toBeInTheDocument(); -}); - -test("nodes in a column are stacked without overlapping", () => { - renderGraph({ showStudentState: true }); - const placed = [ - ...screen.getByTestId("prereq-graph").querySelectorAll("*"), - ] - .filter((element) => element.style.left && element.style.top) - .map((element) => ({ - left: Number.parseFloat(element.style.left), - top: Number.parseFloat(element.style.top), - bottom: - Number.parseFloat(element.style.top) + - Number.parseFloat(element.style.height), - })); - expect(placed.length).toBeGreaterThan(3); - for (const column of new Set(placed.map((item) => item.left))) { - const stacked = placed - .filter((item) => item.left === column) - .sort((left, right) => left.top - right.top); - for (let index = 1; index < stacked.length; index += 1) { - expect(stacked[index].top).toBeGreaterThanOrEqual( - stacked[index - 1].bottom, - ); - } - } -}); diff --git a/apps/web/tests/requisite-diagram.test.tsx b/apps/web/tests/requisite-diagram.test.tsx new file mode 100644 index 00000000..041c7b62 --- /dev/null +++ b/apps/web/tests/requisite-diagram.test.tsx @@ -0,0 +1,137 @@ +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import type { StudentRecord } from "@/lib/coursemap/requisite-evaluation"; +import { RequisiteDiagram } from "@/ui/courses/requisite-diagram"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; +const course = (code: string): CourseRuleExpression => ({ + ...base, + kind: "course", + code, + minimumMark: null, + requirementMode: "completed", +}); + +/** 72 units AND any 2 of three courses, with a permission and an exclusion. */ +const rule: CourseRuleExpression = { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "units_total", subject: null, units: 72 }, + { + kind: "group", + operator: "at_least", + minimumCount: 2, + conditions: [course("COMP2100"), course("COMP2120"), course("COMP2300")], + }, + { ...base, kind: "permission", text: "Apply to the School of Computing." }, + { ...base, kind: "incompatible", code: "COMP3530" }, + ], +}; + +function renderDiagram( + props: Partial[0]> = {}, +) { + return render( + , + ); +} + +test("each requirement is its own box and the group says how many it needs", () => { + renderDiagram(); + expect(screen.getByText("Requires all of")).toBeInTheDocument(); + expect(screen.getByText("72 units in total")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Any 2 of 3" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /COMP2120/u })).toHaveAttribute( + "href", + "/courses/2026/comp2120", + ); +}); + +test("lines merge before the course, so it gets exactly one arrowhead", () => { + renderDiagram(); + const svg = screen.getByTestId("requisite-diagram").querySelector("svg")!; + const heads = [...svg.querySelectorAll("path")].filter((path) => + path.getAttribute("d")?.endsWith("z"), + ); + // One into the course and one into each unlocked course. + expect(heads).toHaveLength(3); + expect(svg.querySelectorAll("circle")).toHaveLength(1); +}); + +test("a permission is a badge on the course, not a prerequisite box", () => { + renderDiagram(); + expect(screen.getByText("Permission needed")).toBeInTheDocument(); + expect( + screen.queryByText("Apply to the School of Computing."), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/cannot be counted with COMP3530/u), + ).toBeInTheDocument(); +}); + +test("unlocked courses without published details still link, marked unavailable", () => { + renderDiagram(); + expect(screen.getByRole("link", { name: /COMP4500/u })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /COMP4550/u })).toHaveAttribute( + "title", + "COMP4550: course details unavailable", + ); +}); + +test("completed requirements say so in words as well as colour", () => { + const student: StudentRecord = { + completed: new Map([["COMP2100", { units: 6, mark: null }]]), + enrolled: new Set(), + programmeCodes: [], + wam: null, + gpa: null, + studyYear: null, + }; + renderDiagram({ student }); + expect( + screen.getByRole("link", { name: /COMP2100.*Done/u }), + ).toBeInTheDocument(); +}); + +test("a course with nothing on either side explains the gap", () => { + renderDiagram({ + expression: null, + unlocks: [], + hasPrerequisiteWording: false, + }); + expect( + screen.getByText( + "COMP3500 has no prerequisites, and no published course lists it as one.", + ), + ).toBeInTheDocument(); +}); + +test("with nothing known to follow, the unlocks column is left out", () => { + renderDiagram({ unlocks: [], unlocksAreKnown: false }); + expect(screen.queryByText("Unlocks")).not.toBeInTheDocument(); + expect(screen.queryByText("Not known yet")).not.toBeInTheDocument(); + expect(screen.getByText("This course")).toBeInTheDocument(); +}); diff --git a/apps/web/tests/requisite-evaluation.test.ts b/apps/web/tests/requisite-evaluation.test.ts new file mode 100644 index 00000000..40af6b8f --- /dev/null +++ b/apps/web/tests/requisite-evaluation.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "vitest"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import { + evaluateRule, + studentRecord, + type StudentRecord, +} from "@/lib/coursemap/requisite-evaluation"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; + +const course = ( + code: string, + extra: Partial<{ + minimumMark: number | null; + requirementMode: "completed" | "completed_or_concurrent"; + }> = {}, +): CourseRuleExpression => ({ + ...base, + kind: "course", + code, + minimumMark: null, + requirementMode: "completed", + ...extra, +}); + +const student: StudentRecord = { + completed: new Map([ + ["COMP1100", { units: 6, mark: 72 }], + ["COMP2100", { units: 6, mark: 58 }], + ["MATH1005", { units: 6, mark: null }], + ]), + enrolled: new Set(["COMP2310"]), + programmeCodes: ["AACOM"], + wam: 68.4, + gpa: 5.4, + studyYear: 2, +}; + +test("unit rules count completed units and report how far along the student is", () => { + expect( + evaluateRule( + { ...base, kind: "units_total", subject: null, units: 24 }, + student, + ), + ).toEqual({ + status: "partial", + measure: { kind: "units", value: 18, target: 24 }, + }); + expect( + evaluateRule( + { ...base, kind: "subject_units", subject: "COMP", units: 12 }, + student, + ).status, + ).toBe("met"); + expect( + evaluateRule( + { + ...base, + kind: "level_units", + minimumLevel: 2000, + maximumLevel: null, + subject: "COMP", + units: 12, + }, + student, + ).measure, + ).toEqual({ kind: "units", value: 6, target: 12 }); +}); + +test("a minimum mark is checked against the recorded mark", () => { + expect( + evaluateRule(course("COMP1100", { minimumMark: 65 }), student), + ).toMatchObject({ status: "met", detail: "You got 72 in COMP1100" }); + expect( + evaluateRule(course("COMP2100", { minimumMark: 65 }), student).status, + ).toBe("unmet"); + expect( + evaluateRule(course("MATH1005", { minimumMark: 65 }), student).status, + ).toBe("unknown"); +}); + +test("a concurrent requisite is met by this semester's enrolment", () => { + expect( + evaluateRule( + course("COMP2310", { requirementMode: "completed_or_concurrent" }), + student, + ).status, + ).toBe("met"); + expect(evaluateRule(course("COMP2310"), student).status).toBe("unmet"); +}); + +test("WAM, GPA and year standing compare against the student's figures", () => { + expect( + evaluateRule({ ...base, kind: "wam", minimumWam: 70 }, student), + ).toMatchObject({ + status: "unmet", + measure: { kind: "score", value: 68.4, threshold: 70, scale: "wam" }, + }); + expect( + evaluateRule({ ...base, kind: "gpa", minimumGpa: 5 }, student).status, + ).toBe("met"); + expect( + evaluateRule({ ...base, kind: "year_standing", minimumYear: 3 }, student), + ).toMatchObject({ status: "unmet", detail: "You're in year 2" }); + expect( + evaluateRule( + { ...base, kind: "wam", minimumWam: 70 }, + { ...student, wam: null }, + ).status, + ).toBe("unknown"); +}); + +test("groups need the number of children their operator asks for", () => { + const atLeastTwo: CourseRuleExpression = { + kind: "group", + operator: "at_least", + minimumCount: 2, + conditions: [course("COMP1100"), course("COMP2100"), course("COMP2300")], + }; + expect(evaluateRule(atLeastTwo, student)).toEqual({ + status: "met", + measure: { kind: "count", value: 2, target: 2 }, + }); + const both: CourseRuleExpression = { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [course("COMP1100"), course("COMP2300")], + }; + expect(evaluateRule(both, student).status).toBe("partial"); +}); + +test("an incompatibility is met only while the other course is not completed", () => { + expect( + evaluateRule({ ...base, kind: "incompatible", code: "COMP1140" }, student) + .status, + ).toBe("met"); + expect( + evaluateRule({ ...base, kind: "incompatible", code: "COMP1100" }, student) + .status, + ).toBe("unmet"); +}); + +test("the student record takes marks and enrolments from the plan", () => { + const record = studentRecord({ + attempts: [ + { + id: "1", + courseCode: "COMP1100", + termId: "t1", + status: "completed", + mark: 80, + unitsEarned: 6, + }, + { + id: "2", + courseCode: "COMP1600", + termId: "t1", + status: "completed", + mark: 60, + unitsEarned: 6, + }, + { id: "3", courseCode: "COMP2310", termId: "t2", status: "enrolled" }, + ], + commencementYear: null, + completedCourses: [ + { code: "COMP1100", units: 6 }, + { code: "COMP1600", units: 6 }, + ], + programmeCodes: ["aacom"], + }); + expect(record.completed.get("COMP1100")).toEqual({ + units: 6, + mark: 80, + tags: [], + }); + expect(record.enrolled.has("COMP2310")).toBe(true); + expect(record.programmeCodes).toEqual(["AACOM"]); + expect(record.wam).toBe(70); + expect(record.studyYear).toBeNull(); +}); + +test("tagged units count completed courses carrying the tag, however it is cased", () => { + const tagged: StudentRecord = { + ...student, + completed: new Map([ + ["PHYS1101", { units: 6, mark: 70, tags: ["science"] }], + ["CHEM1101", { units: 6, mark: 70, tags: ["Science", "Laboratory"] }], + ["ARTH1001", { units: 6, mark: 70, tags: ["Arts"] }], + ]), + }; + expect( + evaluateRule( + { ...base, kind: "tagged_units", tag: "Science", units: 18 }, + tagged, + ), + ).toEqual({ + status: "partial", + measure: { kind: "units", value: 12, target: 18 }, + }); +}); diff --git a/apps/web/tests/requisite-rule-summary.test.tsx b/apps/web/tests/requisite-rule-summary.test.tsx deleted file mode 100644 index 6ce5b5a2..00000000 --- a/apps/web/tests/requisite-rule-summary.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; -import { RequisiteRuleSummary } from "@/ui/courses/requisite-summary"; -import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; - -const base = { - confidence: 1, - hardness: "hard" as const, - reviewState: "automatic" as const, - sourceText: "", -}; - -function renderSummary(expression: CourseRuleExpression) { - render( - - - , - ); -} - -test("reads the whole tree, including the kinds the narrow summary drops", () => { - renderSummary({ - kind: "group", - operator: "all_of", - minimumCount: null, - conditions: [ - { ...base, kind: "subject_units", subject: "COMP", units: 24 }, - { ...base, kind: "year_standing", minimumYear: 3 }, - { - kind: "group", - operator: "any_of", - minimumCount: null, - conditions: [ - { ...base, kind: "subject_units", subject: "MATH", units: 6 }, - { - ...base, - kind: "course", - code: "COMP1600", - minimumMark: null, - requirementMode: "completed", - }, - ], - }, - ], - }); - // Each group says what the reader needs, not a pair of near-identical - // "Complete ... of the following" headings. - expect(screen.getByText("You need all of these")).toBeInTheDocument(); - expect(screen.getByText("You need one of these")).toBeInTheDocument(); - // A unit rule leads with the figure rather than burying it under a category. - expect(screen.getByText("24 units of COMP courses")).toBeInTheDocument(); - expect(screen.getByText("At least year 3 standing")).toBeInTheDocument(); - // An alternative separates its options with "or", so it cannot read as a - // list of things to complete. - expect(screen.getByText("or")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "COMP1600" })).toHaveAttribute( - "href", - "/courses/2026/comp1600", - ); -}); - -test("an at_least group says how many of its options must be met", () => { - renderSummary({ - kind: "group", - operator: "at_least", - minimumCount: 2, - conditions: [ - { ...base, kind: "subject_units", subject: "COMP", units: 6 }, - { ...base, kind: "subject_units", subject: "MATH", units: 6 }, - { ...base, kind: "subject_units", subject: "STAT", units: 6 }, - ], - }); - expect(screen.getByText("You need at least 2 of these")).toBeInTheDocument(); -}); - -test("an incompatibility is flagged rather than read as something to complete", () => { - renderSummary({ ...base, kind: "incompatible", code: "COMP6466" }); - // The code is a link, so the sentence is split across elements. - expect(screen.getByText(/Cannot be counted with/)).toBeInTheDocument(); - expect( - screen.getByLabelText("Incompatible", { selector: "svg" }), - ).toBeInTheDocument(); -}); diff --git a/apps/web/tests/requisite-samples.test.ts b/apps/web/tests/requisite-samples.test.ts new file mode 100644 index 00000000..228c9843 --- /dev/null +++ b/apps/web/tests/requisite-samples.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import { evaluateRule } from "@/lib/coursemap/requisite-evaluation"; +import { sampleStudent } from "@/lib/coursemap/requisite-samples"; + +const base = { + confidence: 1, + hardness: "hard" as const, + reviewState: "automatic" as const, + sourceText: "", +}; +const course = (code: string): CourseRuleExpression => ({ + ...base, + kind: "course", + code, + minimumMark: 65, + requirementMode: "completed", +}); + +const rule: CourseRuleExpression = { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: [ + { ...base, kind: "units_total", subject: null, units: 72 }, + { ...base, kind: "subject_units", subject: "COMP", units: 24 }, + { + ...base, + kind: "level_units", + minimumLevel: 2000, + maximumLevel: null, + subject: "COMP", + units: 12, + }, + { + kind: "group", + operator: "at_least", + minimumCount: 2, + conditions: [course("COMP2100"), course("COMP2120"), course("COMP2300")], + }, + { ...base, kind: "wam", minimumWam: 70 }, + { ...base, kind: "year_standing", minimumYear: 3 }, + { ...base, kind: "structure", structureCode: "AACOM", text: null }, + ], +}; + +test("the complete sample meets every checkable part of the rule", () => { + const student = sampleStudent(rule, "complete"); + expect(evaluateRule(rule, student).status).toBe("met"); +}); + +test("the partway sample is part of the way, not done or untouched", () => { + expect(evaluateRule(rule, sampleStudent(rule, "partway")).status).toBe( + "partial", + ); +}); + +test("the new student has nothing on their record", () => { + const student = sampleStudent(rule, "new"); + expect(student.completed.size).toBe(0); + expect(evaluateRule(rule, student).status).toBe("unmet"); +}); diff --git a/apps/web/tests/requisite-summary.test.mjs b/apps/web/tests/requisite-summary.test.mjs deleted file mode 100644 index 8bda3bcb..00000000 --- a/apps/web/tests/requisite-summary.test.mjs +++ /dev/null @@ -1,148 +0,0 @@ -import assert from "node:assert/strict"; - -import { test } from "vitest"; - -const { evaluateRequisiteExpression } = - await import("../lib/coursemap/requisite-summary.ts"); - -test("evaluates level and total unit progress from completed courses", () => { - const levelExpression = { - kind: "level_units", - units: 12, - level: 2000, - subject: "COMP", - }; - assert.deepEqual( - evaluateRequisiteExpression(levelExpression, [ - { code: "COMP2100F", units: 6 }, - { code: "COMP2300P", units: 6 }, - { code: "COMP1100", units: 6 }, - { code: "MATH2222", units: 6 }, - ]), - { - kind: "level_units", - level: 2000, - subject: "COMP", - requiredUnits: 12, - completedUnits: 12, - satisfied: true, - }, - ); - - const totalExpression = { kind: "units_total", units: 24 }; - assert.deepEqual( - evaluateRequisiteExpression(totalExpression, [ - { code: "COMP1100", units: 6 }, - { code: "MATH1005", units: 6 }, - ]), - { - kind: "units_total", - requiredUnits: 24, - completedUnits: 12, - satisfied: false, - }, - ); -}); - -test("evaluates subject units and alternatives from completed courses only", () => { - const expression = { - kind: "group", - operator: "all_of", - conditions: [ - { kind: "subject_units", subject: "COMP", units: 24 }, - { - kind: "group", - operator: "any_of", - conditions: [ - { kind: "subject_units", subject: "MATH", units: 6 }, - { kind: "course", code: "COMP1600" }, - ], - }, - ], - }; - - assert.deepEqual( - evaluateRequisiteExpression(expression, [ - { code: "COMP1100", units: 6 }, - { code: "COMP1110", units: 6 }, - { code: "COMP2100", units: 6 }, - { code: "COMP2300", units: 6 }, - { code: "MATH1005", units: 6 }, - ]), - { - kind: "group", - operator: "all_of", - satisfied: true, - conditions: [ - { - kind: "subject_units", - subject: "COMP", - requiredUnits: 24, - completedUnits: 24, - satisfied: true, - }, - { - kind: "group", - operator: "any_of", - satisfied: true, - conditions: [ - { - kind: "subject_units", - subject: "MATH", - requiredUnits: 6, - completedUnits: 6, - satisfied: true, - }, - { kind: "course", code: "COMP1600", satisfied: false }, - ], - }, - ], - }, - ); -}); - -test("evaluates programme enrolment against the student's programmes", () => { - const expression = { - kind: "group", - operator: "all_of", - conditions: [ - { kind: "course", code: "ACST4031" }, - { - kind: "group", - operator: "any_of", - conditions: [ - { - kind: "programme_enrolment", - code: "HACTS", - name: "Bachelor of Actuarial Studies (Honours)", - }, - { - kind: "programme_enrolment", - code: "ASSAE", - name: "Bachelor of Social Sciences (Honours in Actuarial Studies and Economics)", - }, - ], - }, - ], - }; - - const enrolled = evaluateRequisiteExpression( - expression, - [{ code: "ACST4031", units: 6 }], - ["HACTS"], - ); - assert.equal(enrolled.satisfied, true); - - const notEnrolled = evaluateRequisiteExpression( - expression, - [{ code: "ACST4031", units: 6 }], - [], - ); - assert.equal(notEnrolled.satisfied, false); - assert.deepEqual(notEnrolled.conditions[1].conditions[0], { - kind: "programme_enrolment", - code: "HACTS", - name: "Bachelor of Actuarial Studies (Honours)", - satisfied: false, - }); -}); diff --git a/apps/web/ui/admin/catalogue/version-preview.tsx b/apps/web/ui/admin/catalogue/version-preview.tsx index d9cb3642..31581988 100644 --- a/apps/web/ui/admin/catalogue/version-preview.tsx +++ b/apps/web/ui/admin/catalogue/version-preview.tsx @@ -1,10 +1,14 @@ "use client"; -import { Tabs } from "@coursemap/ui/primitives/tabs"; -import { useState } from "react"; +import { Tabs, TabsList, TabsTrigger } from "@coursemap/ui/primitives/tabs"; +import { useMemo, useState } from "react"; import type { CatalogueContent } from "@/lib/catalogue/content"; import type { CourseDetails } from "@/lib/coursemap/course-types"; +import { + sampleStudent, + type SampleStudent, +} from "@/lib/coursemap/requisite-samples"; import { structureDetailsFromWrite } from "@/lib/coursemap/structure-version-view"; import { CourseDetailTabsList, @@ -20,19 +24,58 @@ import { const EMPTY = { completedCourses: [], isAuthenticated: false }; -/** The student view of a course snapshot, rendered from its projection. */ +const PREVIEW_READERS = [ + { value: "signed-out", label: "Signed out" }, + { value: "new", label: "New student" }, + { value: "partway", label: "Partway" }, + { value: "complete", label: "Meets everything" }, +] as const; + +type PreviewReader = (typeof PREVIEW_READERS)[number]["value"]; + +/** + * The student view of a course snapshot, rendered from its projection. The + * reader switch fills the requisites with a made-up student built from this + * course's own rule, so every progress state can be checked without a real + * record. + */ export function CoursePreview({ course }: { course: CourseDetails }) { const [tab, setTab] = useState("overview"); + const [reader, setReader] = useState("signed-out"); + const rule = course.prerequisiteRule?.relationalExpression ?? null; + const student = useMemo( + () => + reader === "signed-out" + ? null + : sampleStudent(rule, reader as SampleStudent), + [reader, rule], + ); return ( setTab(value as CourseTab)} className="block" > -
+
+ setReader(value as PreviewReader)} + > + + {PREVIEW_READERS.map((option) => ( + + {option.label} + + ))} + +
- + ); } diff --git a/apps/web/ui/courses/course-detail-client.tsx b/apps/web/ui/courses/course-detail-client.tsx index 4ba2e60d..589b49fa 100644 --- a/apps/web/ui/courses/course-detail-client.tsx +++ b/apps/web/ui/courses/course-detail-client.tsx @@ -63,6 +63,7 @@ export function CourseDetailClient({ }> setPlanOpen(true)} requisiteCompletion={requisiteCompletion} diff --git a/apps/web/ui/courses/course-detail-view.tsx b/apps/web/ui/courses/course-detail-view.tsx index dc53e0ff..d00b9bb3 100644 --- a/apps/web/ui/courses/course-detail-view.tsx +++ b/apps/web/ui/courses/course-detail-view.tsx @@ -32,6 +32,7 @@ import { BookOpen, Banknote, CalendarClock, + ChevronRight, ArrowUpRight, ClipboardCheck, GitBranch, @@ -42,14 +43,13 @@ import { Plus, } from "lucide-react"; import { Hint } from "@/ui/common/hint"; -import { PrereqGraph } from "@/ui/prereq-graph"; import type { CourseDetails } from "@/lib/coursemap/course-types"; -import { requirementCourseStatus } from "@/lib/coursemap/requirement-display"; import type { Attempt } from "@/lib/coursemap/types"; +import type { CompletedRequisiteCourse } from "@/lib/coursemap/requisite-summary"; import { - evaluateRequisiteExpression, - type CompletedRequisiteCourse, -} from "@/lib/coursemap/requisite-summary"; + studentRecord, + type StudentRecord, +} from "@/lib/coursemap/requisite-evaluation"; import { feeValue, formatDate, @@ -59,11 +59,9 @@ import { unitValueLabel, } from "@/ui/courses/course-detail-format"; import { CourseReferenceText } from "@/ui/courses/course-reference"; -import { - RequisiteExpressionSummary, - RequisiteProgressSummary, - RequisiteRuleSummary, -} from "@/ui/courses/requisite-summary"; +import { EnrolmentSteps } from "@/ui/courses/enrolment-steps"; +import { RequisiteDiagram } from "@/ui/courses/requisite-diagram"; +import { RequisiteExpressionSummary } from "@/ui/courses/requisite-summary"; export const courseDetailTabs = [ { id: "overview", label: "Overview", icon: BookOpen }, @@ -107,14 +105,23 @@ const NO_ATTEMPTS: readonly Attempt[] = []; */ export function CourseDetailView({ attempts = NO_ATTEMPTS, + commencementYear = null, course, onAddToPlan, + previewStudent, requisiteCompletion, }: { - /** The reader's own plan, so the graph can mark what they have done. */ + /** The reader's own plan, so the requisites can mark what they have done. */ attempts?: readonly Attempt[]; + /** The year the reader started their degree, for year-standing rules. */ + commencementYear?: number | null; course: CourseDetails; onAddToPlan?: () => void; + /** + * A sample student for the admin preview. When given, it replaces the + * reader's own record, and null previews a signed-out reader. + */ + previewStudent?: StudentRecord | null; requisiteCompletion: { completedCourses: CompletedRequisiteCourse[]; enrolledProgrammeCodes?: string[]; @@ -124,21 +131,17 @@ export function CourseDetailView({ const availableCourseCodes = new Set(course.availableCourseCodes); const structuredRule = course.prerequisiteRule?.expression ?? null; const relationalRule = course.prerequisiteRule?.relationalExpression ?? null; - const statusByCode = new Map( - [...new Set(attempts.map((attempt) => attempt.courseCode))].flatMap( - (attemptCode) => { - const status = requirementCourseStatus(attemptCode, attempts); - return status ? [[attemptCode, status] as const] : []; - }, - ), - ); - const requisiteProgress = structuredRule - ? evaluateRequisiteExpression( - structuredRule, - requisiteCompletion.completedCourses, - requisiteCompletion.enrolledProgrammeCodes ?? [], - ) - : null; + const student = + previewStudent !== undefined + ? previewStudent + : requisiteCompletion.isAuthenticated + ? studentRecord({ + attempts, + commencementYear, + completedCourses: requisiteCompletion.completedCourses, + programmeCodes: requisiteCompletion.enrolledProgrammeCodes ?? [], + }) + : null; const hasPrerequisiteWording = course.prerequisiteText.trim().length > 0 && !/^No prerequisites listed\.?$/iu.test(course.prerequisiteText.trim()); @@ -503,138 +506,136 @@ export function CourseDetailView({ - 0 - } - statusByCode={statusByCode} + student={student} + unlocks={course.prerequisiteEdges + .filter( + (edge) => + edge.from === course.code && edge.to !== course.code, + ) + .map((edge) => ({ + code: edge.to, + isAvailable: edge.toIsAvailable, + }))} unlocksAreKnown={course.unlocksAreKnown} /> - - + + -

{"Requisites and compatibility"}

+

{"Enrolment requirements"}

- - {requisiteProgress && requisiteCompletion.isAuthenticated ? ( -
-

- Prerequisites against your completed courses -

-
- -
-
- ) : relationalRule ? ( -
-

- Prerequisite requirements -

-
- -
-
+ + {relationalRule ? ( + ) : structuredRule ? ( -
-

- Coursemap summary -

-
- -
-
- ) : null} -
-

- Prerequisites as published -

-

- + -

-
- {course.corequisiteText ? ( -
-

- Corequisites -

-

- -

-
- ) : null} - {course.inherentRequirements ? ( -
-

- Inherent requirements -

-

- {course.inherentRequirements} -

- ) : null} - {course.assumedKnowledgeText ? ( -
-

- Assumed knowledge -

-

- {course.assumedKnowledgeText} -

-
- ) : null} - {course.permissionText ? ( -
-

- Permission -

-

- {course.permissionText} -

-
- ) : null} - {course.incompatibilityText ? ( -
-

- Incompatibilities + ) : ( +
+

+ Prerequisites

+ )} + {[ + { + title: "Corequisites", + text: course.corequisiteText, + linked: true, + }, + { + title: "Assumed knowledge", + text: course.assumedKnowledgeText, + linked: false, + }, + { + title: "Inherent requirements", + text: course.inherentRequirements, + linked: false, + }, + ].map((section) => + section.text ? ( +
+

+ {section.title} +

+

+ {section.linked ? ( + + ) : ( + section.text + )} +

+
+ ) : null, + )} + {/* The steps are Coursemap's reading of ANU's wording, so the + wording stays one click away to check them against. */} + {relationalRule || + structuredRule || + course.permissionText || + course.incompatibilityText ? ( +
+ + +
+ {[ + relationalRule || structuredRule + ? course.prerequisiteText + : "", + course.permissionText, + course.incompatibilityText, + ] + .filter((text): text is string => Boolean(text?.trim())) + .map((text, index) => ( +

+ +

+ ))} +
+
) : null} diff --git a/apps/web/ui/courses/enrolment-steps.tsx b/apps/web/ui/courses/enrolment-steps.tsx new file mode 100644 index 00000000..04b57894 --- /dev/null +++ b/apps/web/ui/courses/enrolment-steps.tsx @@ -0,0 +1,547 @@ +import Link from "next/link"; +import { + ArrowUpRight, + Check, + CircleSlash, + Info, + KeyRound, + X, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import { + evaluateRule, + groupRequiredCount, + type ConditionEvaluation, + type RequisiteStatus, + type StudentRecord, +} from "@/lib/coursemap/requisite-evaluation"; +import type { CourseRuleCondition } from "@/lib/coursemap/requisite-tree"; +import { + groupSentence, + requisiteExplorerLink, + requisiteNoun, + requisiteSentence, + splitRequisiteRule, +} from "@/ui/courses/requisite-wording"; + +type Group = Extract; +type Marker = RequisiteStatus | "permission" | "note" | "blocked"; +type ChipState = "done" | "enrolled" | "todo"; + +type Step = { + title: string; + /** The explorer, filtered to the courses that count towards this step. */ + link?: { href: string; label: string } | null; + marker: Marker; + counted: boolean; + body: ReactNode; +}; + +function Chip({ + code, + state, + href, +}: { + code: string; + state: ChipState; + href: string | null; +}) { + const className = cn( + "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-xs font-semibold", + state === "done" + ? "border-success/40 bg-success/10 text-success" + : state === "enrolled" + ? "border-primary/40 bg-primary/10 text-primary" + : href + ? "border-border bg-card text-primary hover:border-primary/50" + : "border-border bg-muted/40 text-muted-foreground", + ); + const content = ( + <> + {state === "done" ? ( +