Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/web/lib/catalogue/record-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { CatalogueKind } from "./content.ts";

const COURSE_CODE = /^[A-Z]{4}\d{4}[A-Z]?$/u;
const STRUCTURE_CODE = /^[A-Z0-9][A-Z0-9-]{1,31}$/u;
const ANU_RECORD_PATH =
/^\/(?:\d{4}\/)?(course|program|major|minor|specialisation)\/([A-Za-z0-9-]+)\/?$/iu;
const ANU_HOST = "programsandcourses.anu.edu.au";

/** The catalogue kind an ANU code belongs to, read from its shape. */
export function catalogueKindForCode(code: string): CatalogueKind {
if (COURSE_CODE.test(code)) return "course";
if (code.endsWith("-MAJ")) return "major";
if (code.endsWith("-MIN")) return "minor";
if (/-(?:HSPC|SPEC)$/u.test(code)) return "specialisation";
return "programme";
}

/**
* The Coursemap record a link in imported text points at, if any. The model
* writes a record either as its bare code, from the page's own links, or as
* the full ANU address it copied; both open the record in Coursemap rather
* than sending the reader back to ANU.
*/
export function catalogueRecordFromReference(
target: string,
): { kind: CatalogueKind; code: string } | null {
const trimmed = target.trim();
// A link target already written as an upper-case code is a record; words
// and addresses are not.
if (STRUCTURE_CODE.test(trimmed)) {
return { kind: catalogueKindForCode(trimmed), code: trimmed };
}
let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.hostname !== ANU_HOST) return null;
const match = ANU_RECORD_PATH.exec(url.pathname);
if (!match) return null;
const code = match[2].toUpperCase();
const kind =
match[1].toLowerCase() === "program"
? "programme"
: (match[1].toLowerCase() as CatalogueKind);
return { kind, code };
}
68 changes: 68 additions & 0 deletions apps/web/tests/catalogue-markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { TooltipProvider } from "@coursemap/ui/primitives/tooltip";
import { expect, test } from "vitest";

import { CatalogueMarkdown } from "@/ui/common/catalogue-markdown";

function renderMarkdown(markdown: string, available: string[] = []) {
return render(
<TooltipProvider>
<CatalogueMarkdown
markdown={markdown}
academicYear={2026}
availableCourseCodes={new Set(available)}
/>
</TooltipProvider>,
);
}

test("a recommended-course list reads as a list with course links", () => {
renderMarkdown(
[
"**What courses should you take in first year?**",
"",
"- MATH1115 Advanced Mathematics and Applications 1",
"- MATH1116 Advanced Mathematics and Applications 2",
].join("\n"),
["MATH1115"],
);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.getByRole("link", { name: "MATH1115" })).toHaveAttribute(
"href",
"/courses/2026/math1115",
);
// An unpublished course is named but not linked.
expect(screen.queryByRole("link", { name: "MATH1116" })).toBeNull();
expect(
screen
.getByText("What courses should you take in first year?")
.closest("strong"),
).not.toBeNull();
});

test("record links stay in Coursemap and other links leave safely", () => {
renderMarkdown(
"Take it with a [Mathematics Major](http://programsandcourses.anu.edu.au/major/MATH-MAJ) or [Bachelor of Arts](BARTS). [Apply](https://study.anu.edu.au/apply) or email students.cos@anu.edu.au.",
);
expect(
screen.getByRole("link", { name: "Mathematics Major" }),
).toHaveAttribute("href", "/majors/2026/math-maj");
expect(
screen.getByRole("link", { name: "Bachelor of Arts" }),
).toHaveAttribute("href", "/programmes/2026/barts");
const external = screen.getByRole("link", { name: "Apply" });
expect(external).toHaveAttribute("target", "_blank");
expect(external).toHaveAttribute("rel", "noopener noreferrer");
expect(
screen.getByRole("link", { name: "students.cos@anu.edu.au" }),
).toHaveAttribute("href", "mailto:students.cos@anu.edu.au");
});

test("markup in imported text is shown as text, never run", () => {
const { container } = renderMarkdown(
'<img src=x onerror="alert(1)"> [click](javascript:alert(1))',
);
expect(container.querySelector("img")).toBeNull();
expect(screen.queryByRole("link", { name: "click" })).toBeNull();
expect(screen.getByText(/<img src=x/u)).toBeInTheDocument();
});
39 changes: 39 additions & 0 deletions apps/web/tests/record-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from "vitest";

import {
catalogueKindForCode,
catalogueRecordFromReference,
} from "@/lib/catalogue/record-reference";

test("a code's shape names its catalogue kind", () => {
expect(catalogueKindForCode("MATH1115")).toBe("course");
expect(catalogueKindForCode("MATH-MAJ")).toBe("major");
expect(catalogueKindForCode("AARB-MIN")).toBe("minor");
expect(catalogueKindForCode("ADMA-SPEC")).toBe("specialisation");
expect(catalogueKindForCode("COMP-HSPC")).toBe("specialisation");
expect(catalogueKindForCode("BARTS")).toBe("programme");
});

test("record links open in Coursemap whether written as a code or an ANU address", () => {
expect(catalogueRecordFromReference("BARTS")).toEqual({
kind: "programme",
code: "BARTS",
});
expect(
catalogueRecordFromReference(
"http://programsandcourses.anu.edu.au/major/MATH-MAJ",
),
).toEqual({ kind: "major", code: "MATH-MAJ" });
expect(
catalogueRecordFromReference(
"https://programsandcourses.anu.edu.au/2026/program/AACOM",
),
).toEqual({ kind: "programme", code: "AACOM" });
expect(
catalogueRecordFromReference("https://study.anu.edu.au/apply"),
).toBeNull();
expect(
catalogueRecordFromReference("mailto:students.cos@anu.edu.au"),
).toBeNull();
expect(catalogueRecordFromReference("apply")).toBeNull();
});
130 changes: 130 additions & 0 deletions apps/web/tests/structure-detail-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import { Tabs } from "@coursemap/ui/primitives/tabs";
import { TooltipProvider } from "@coursemap/ui/primitives/tooltip";
import { expect, test } from "vitest";

import type { StructureDetails } from "@/lib/coursemap/structure-types";
import { readingTreeContext } from "@/ui/requirements/requirement-presentation";
import { StructureDetailView } from "@/ui/requirements/structure-detail-view";

function structure(
overrides: Partial<StructureDetails> = {},
): StructureDetails {
return {
code: "AARB-MIN",
kind: "minor",
year: 2026,
name: "Advanced Arabic",
acronym: null,
shortName: null,
introduction: "Build on intermediate Arabic.",
description: null,
units: 24,
durationYears: null,
academicCareer: "Undergraduate",
college: "ANU College of Arts and Social Sciences",
modeOfDelivery: null,
selectionRank: null,
atar: null,
studyAs: null,
contactText: null,
sections: [],
learningOutcomes: [],
fees: [],
relationships: [],
requirements: null,
...overrides,
};
}

function renderView(tab: string, details: StructureDetails) {
return render(
<TooltipProvider>
<Tabs value={tab}>
<StructureDetailView
structure={details}
treeContext={readingTreeContext({ academicYear: 2026 })}
/>
</Tabs>
</TooltipProvider>,
);
}

test("a minor names the degrees it is offered in, by title", () => {
renderView(
"overview",
structure({
relationships: [
{
position: 1,
relationshipKind: "offered_in",
targetKind: "programme",
targetCode: "BARTS",
targetTitle: "Bachelor of Arts",
},
{
position: 2,
relationshipKind: "offered_in",
targetKind: "programme",
targetCode: "ELANG",
targetTitle: "Diploma of Languages",
},
],
}),
);
expect(
screen.getByRole("heading", { name: "Offered in" }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Bachelor of Arts/u }),
).toHaveAttribute("href", "/programmes/2026/barts");
expect(screen.queryByText(/Mentioned by the ANU page/u)).toBeNull();
});

test("a programme groups the structures it offers by kind", () => {
renderView(
"overview",
structure({
code: "AACOM",
kind: "programme",
relationships: [
{
position: 1,
relationshipKind: "option",
targetKind: "specialisation",
targetCode: "ARIN-SPEC",
targetTitle: "Artificial Intelligence",
},
],
}),
);
expect(
screen.getByRole("heading", { name: "Choose from" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Specialisations" }),
).toBeInTheDocument();
});

test("information reads as tidy sections without a jump list for one card", () => {
renderView(
"information",
structure({
sections: [
{
position: 1,
sectionKey: "first_year_advice",
heading: "First-year advice",
markdown:
"- MATH1115 Advanced Mathematics and Applications 1\n- MATH1116 Advanced Mathematics and Applications 2",
},
],
}),
);
expect(
screen.getByRole("heading", { name: "First-year advice" }),
).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.queryByRole("navigation")).toBeNull();
expect(screen.queryByText(/Back to the top/iu)).toBeNull();
});
Loading
Loading