From fa6d5c78afc5b4c57b6db5589e4b765c86813165 Mon Sep 17 00:00:00 2001 From: Akram Kadyrov Date: Tue, 15 Sep 2026 01:22:57 -0400 Subject: [PATCH] Add grade distributions from the records request Five academic years of per-section grade distributions, obtained under R.C. 149.43 because OSU publishes nothing like them, folded into one curve per instructor per course and drawn in the detail pane under the ratings. This is the one join in Finder that does not guess. RateMyProfessors has to be matched on a name, which is why two professors called Alan Reed get nothing; the records request asked for the instructor's OSU address, which content.osu.edu already publishes beside every section, so a curve keys on bucci.2 and lands on one person or on nobody. The pane leads with the mean and the share who got an A or A-, because the two disagree where it matters: half A and half E averages the same as entirely B. Withdrawals sit beside the curve rather than inside it, since nobody who withdrew has a grade to move a mean with. Every way a curve can be absent reads as absent rather than as a number. A course whose sections were all too small to publish, a pass-fail course and an instructor with no record are three different sentences, and gradesStatus keeps them apart. data/grades.json is not in the tree until the request is fulfilled. A 404 reads as unpublished and is never retried; a 500 stays retryable. Co-authored-by: Claude Opus 5 --- README.md | 59 +++- css/finder.css | 16 ++ docs/osu-grades.md | 176 ++++++++++++ js/app.js | 7 +- js/detail.js | 103 +++++++ js/grades.js | 284 ++++++++++++++++++ scripts/fetch-grades.mjs | 446 +++++++++++++++++++++++++++++ tests/detail-grades-absent.test.js | 54 ++++ tests/detail-grades.test.js | 138 +++++++++ tests/fetch-grades.test.js | 253 ++++++++++++++++ tests/fixtures.js | 88 ++++++ tests/grades.test.js | 222 ++++++++++++++ tests/helpers.js | 22 +- 13 files changed, 1858 insertions(+), 10 deletions(-) create mode 100644 docs/osu-grades.md create mode 100644 js/grades.js create mode 100644 scripts/fetch-grades.mjs create mode 100644 tests/detail-grades-absent.test.js create mode 100644 tests/detail-grades.test.js create mode 100644 tests/fetch-grades.test.js create mode 100644 tests/grades.test.js diff --git a/README.md b/README.md index fdb5fa2..88b98f5 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,50 @@ The counts live in `data/ratings-courses.json`, 151 KB gzipped and read only by the detail pane, so it is fetched when you open the first section rather than when the page loads. +### What the registrar recorded, next to what students said + +A rating is what people felt. `data/grades.json` is what Ohio State wrote +down: five academic years of per-section grade distributions, Autumn 2021 +through Spring 2026, obtained under Ohio's public records law because OSU +publishes nothing like it. `scripts/fetch-grades.mjs` folds that spreadsheet +into one curve per instructor per course. + +This is the one join in Finder that does not have to guess. RateMyProfessors +has to be matched on a name, which is why two professors called Alan Reed get +no rating at all. The records request asked for the instructor's OSU address, +which `content.osu.edu` already publishes beside every section, so a curve is +keyed on `bucci.2` and lands on one person or on nobody. + +The pane leads with the average and the share who got an A or A-, because the +two disagree exactly where it matters. A section that is half A and half E +averages the same as a section that is entirely B, and only one of them is a +coin flip. + +Withdrawals sit beside the curve rather than inside it. Nobody who withdrew +has a grade, so they cannot move a mean, which means a course a third of the +class drops can post a flattering one. The number that explains it has to be +on screen next to it. + +### A curve is a cohort, not a grading style + +The same professor teaching the same course to honours students in Autumn and +to a summer section averages two different curves, and neither is their +"difficulty". Finder prints how many sections and how many terms went into +each one so the number carries its own sample size, and says so outright under +thirty students. It cannot tell you who the students were, and that is usually +the larger half of the answer. + +Sections too small to publish are missing on purpose. A distribution over four +students can identify one of them, so Ohio State withholds those, and the +request asked for the withheld rows to be marked rather than dropped. That is +why the pane can say "two more sections were too small to publish" instead of +quietly showing a curve with a hole in it. A course where every section was +withheld says that too, rather than looking like a professor with no record. + +`data/grades.json` is absent until the request is fulfilled. The loader reads +a 404 as "not published yet" rather than as a failure, so the block simply does +not appear, and nothing else on the page changes. + ## What one search does ```mermaid @@ -170,16 +214,16 @@ on Ohio State alone. Only the newest search is allowed to draw, so a slow first search can never paint over a fast second one. -That diagram is only the part a search waits on. Opening the page is 29 requests +That diagram is only the part a search waits on. Opening the page is 30 requests across two hosts, counted in Chrome against a local copy with the cache -cleared: 28 files from this repo and the term list from `content.osu.edu`. The -28 are the HTML, two stylesheets, three font files, eighteen modules, the +cleared: 29 files from this repo and the term list from `content.osu.edu`. The +29 are the HTML, two stylesheets, three font files, nineteen modules, the ratings snapshot, two seat files and the favicon. The live page adds one more, the page-view ping to the analytics worker, which local runs skip. Opening a section adds requests the page load does not make: the rating course -codes, the list of instructors who have a photo, and, if this one does, their -headshot from `opic.osu.edu`. That is Ohio State's own photo service and a third +codes, the grade curves, the list of instructors who have a photo, and, if this +one does, their headshot from `opic.osu.edu`. That is Ohio State's own photo service and a third host, and the request tells it which instructor was opened. The share card is not one of them. `og.png` is fetched by whatever is unfurling @@ -238,6 +282,7 @@ js/ sort.js ordering by rating, difficulty, seats or start time deeplink.js the section a link points at ratings.js RMP snapshot and name matching + grades.js five years of grade curves, joined by OSU address headshots.js which instructors have a photo, and the initials fallback seats.js Barrett snapshot and the term guard trend.js what moved between two seat snapshots @@ -246,9 +291,9 @@ js/ hit.js one page-view ping, skipped on localhost analytics.js the one place the worker URL lives stats.js the /stats dashboard -scripts/ the four snapshot jobs +scripts/ the four snapshot jobs, and the records import data/ the snapshots, committed -docs/ what OSU's API and Barrett's schedule get wrong +docs/ what OSU's API, Barrett's schedule and the records get wrong tests/ node:test, zero dependencies wireframes/ three layouts considered first analytics/ the page-view counter, a Cloudflare Worker diff --git a/css/finder.css b/css/finder.css index edc1dfc..adbd6c9 100644 --- a/css/finder.css +++ b/css/finder.css @@ -743,6 +743,22 @@ body { .spread i.s4 { background: var(--open); opacity: 0.55; } .spread i.s5 { background: var(--open); } +/* The grade curve, A on the left. Eleven marks banded into the three colours + the rest of the page already uses, because eleven distinguishable hues would + be decoration: what this bar has to show is how much is A, how much is the + middle, and how much is E. */ +.gspread i.g0 { background: var(--open); } +.gspread i.g1 { background: var(--open); opacity: 0.75; } +.gspread i.g2 { background: var(--open); opacity: 0.5; } +.gspread i.g3 { background: var(--wait); opacity: 0.85; } +.gspread i.g4 { background: var(--wait); opacity: 0.65; } +.gspread i.g5 { background: var(--wait); opacity: 0.5; } +.gspread i.g6 { background: var(--scarlet); opacity: 0.45; } +.gspread i.g7 { background: var(--scarlet); opacity: 0.6; } +.gspread i.g8 { background: var(--scarlet); opacity: 0.75; } +.gspread i.g9 { background: var(--scarlet); opacity: 0.88; } +.gspread i.g10 { background: var(--scarlet); } + .spread-cap { margin-bottom: 0.6rem; } .section:focus-visible { diff --git a/docs/osu-grades.md b/docs/osu-grades.md new file mode 100644 index 0000000..8408f9e --- /dev/null +++ b/docs/osu-grades.md @@ -0,0 +1,176 @@ +# The grade distribution records + +Ohio State publishes course grade distributions nowhere. There is no API for +them, no page on the registrar's site, and nothing in `content.osu.edu/v2`. +The file behind `js/grades.js` came from a public records request instead, and +this is what it covers, what it cannot answer, and what has to be true of the +next one. + +## The request + +Filed under the Ohio Public Records Act, R.C. 149.43, which makes records of a +public university available to any person. Grade distributions aggregated to a +section carry no student identifiers, so they are ordinary public records; the +request nevertheless conceded the small-enrolment suppression below, because +it costs a handful of sections and it is the difference between a fulfilled +request and an argument. + +Asked for, per section, for Autumn 2021 through Spring 2026: + +| Field | Why | +| --- | --- | +| Term, and the term code | Orders the five years, and `1218` style codes sort without parsing | +| Subject and catalog number | The course key the site already searches on | +| Class number and section number | Two different fields at OSU. `classNumber` 5168 is what BuckeyeLink wants; `section` 0010 is the number within the course | +| Campus | Columbus only is what the site searches; regional sections would otherwise land on a Columbus professor | +| Primary instructor name **and OSU email** | The email is the join. See below | +| Counts per letter grade | A through E, plus S, U, PA, NP, W and I | +| Total enrolled | Lets a row be checked against the counts that are supposed to sum to it | + +## The email is the whole point + +Every other join in Finder guesses. `js/ratings.js` matches RateMyProfessors on +a first and last name, drops middle names and suffixes, and returns nothing +when two people share a name, because a wrong rating is worse than no rating. +That costs real coverage: both Alan Reeds get nothing. + +`content.osu.edu/v2` already publishes an instructor's address beside every +section — `{"displayName": "Paolo Bucci", "email": "bucci.2@osu.edu"}` — and +the records request asked for the same field. So a grade curve is keyed on +`bucci.2` and joins exactly, to one person or to nobody. + +Name matching survives as a fallback for rows that came back without an +address, and it inherits the same refusal: two people under one name return +nothing. + +## What the file cannot tell you + +**A curve is a cohort.** The largest thing moving a distribution is usually who +enrolled, not who taught. Honours sections, majors-only sections, summer +sections and 8am sections are different populations taking the same course +number from the same person. Nothing in these records separates them, so a +curve is evidence about a class, not a measurement of a professor. + +**Small sections are missing.** Ohio State withholds distributions for sections +small enough that one student's grade could be inferred. The request asked for +those rows to be retained and flagged rather than dropped, so the snapshot +carries a `suppressed` count per course and the pane can say how many sections +are not in the curve. Where every section of a course was withheld, the record +exists and the curve does not, which `gradesStatus` reports as `withheld` — +distinct from a professor with no record at all. + +**Withdrawals are not in the mean.** They cannot be: a W carries no grade +points. That makes the mean of a course a third of the class drops flattering, +so the withdrawal count and its share sit beside the curve rather than inside +it. + +**Pass-fail courses have no curve.** S, U, PA and NP carry no points either. A +course graded entirely on them reports `ungraded` rather than an empty +distribution, which would read as a course nobody passed. + +**It stops where the live data starts.** Grades exist for terms that are over. +The terms the site searches are the ones OSU's API exposes, which are current +and future. The two never overlap, by construction. + +**Team-taught sections are attributed to one person.** The request asked for +the primary instructor, so a section with co-instructors puts its whole +distribution under the PI. Finder draws no curve for a section listing more +than one instructor, for the reason it draws no rating: an average across two +people is not either person's. + +## The import + +`scripts/fetch-grades.mjs` reads the spreadsheet off disk. It fetches nothing +— the records office sends a file once: + +```bash +node scripts/fetch-grades.mjs records.csv +``` + +Save an XLSX as CSV first. The script reads one sheet of flat rows and nothing +about that needs a spreadsheet parser. + +**The column names are not known in advance.** They are whatever the +registrar's export tool produced. Every field is resolved through the alias +tables at the top of the script, and an unresolved one aborts the run printing +the headers the file actually had: + +``` +Refusing to write data/grades.json. +grades: could not find grade B-, instructor email or name. +The file's headers are: Term | Subject | Catalog | Instructor | A | A- | B+ | B | ... +Add the spelling to HEADERS or GRADE_ALIASES in scripts/fetch-grades.mjs. +``` + +Adding the spelling is the entire adaptation step. It refuses rather than +guesses because the failure it is avoiding is silent: a file whose `B-` column +went unrecognised would write a curve missing every B-minus in five years and +look completely normal doing it. + +One normalising rule is worth knowing about. Field names have their +punctuation stripped, so `catalog_nbr`, `Catalog Nbr` and `CATALOGNBR` are one +spelling. Grade columns do **not**: `A-` would normalise to `a`, which is also +what `A` normalises to, and both columns would resolve to whichever came +first. Every A-minus in the file would be read out of the A column. Grade +headers keep their sign, which is why `markSlug` exists next to `slug`. + +### What it refuses to write + +The same gate as the other three snapshotters, from `scripts/guards.mjs`: + +- Under 200 instructors, which is a file the parser did not understand rather + than a small year. Not forceable. +- More than 10% below the committed count, which is a partial parse. Forceable + with `FORCE_WRITE=1`, because a corrected smaller dataset is a real thing to + ship. +- More than 10% of rows unreadable. Rows from another campus and rows taught by + "Staff" are skipped rather than failed, and do not count toward this. + +`Staff`, `TBA`, `TBD` and `Unassigned` are dropped outright. Folding five years +of unstaffed sections into one key would invent the most prolific instructor +at the university. + +## The shape it writes + +```json +{ + "source": "The Ohio State University, public records request under R.C. 149.43", + "campus": "columbus", + "firstTerm": "1218", + "lastTerm": "1262", + "termCount": 15, + "scale": ["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"], + "count": 4211, + "instructors": { + "bucci.2": { + "name": "Paolo Bucci", + "terms": 9, + "courses": { + "CSE 2221": { + "counts": [180, 120, 95, 140, 60, 45, 70, 25, 15, 20, 90], + "other": { "W": 61, "I": 4 }, + "sections": 22, + "terms": 9, + "suppressed": 2 + } + } + } + } +} +``` + +`counts` is positional against `scale`, which the file states rather than +assumes, so a reader never has to know the order from somewhere else. +`suppressed` is sections withheld, not students. + +Written compact rather than indented, like `data/ratings-courses.json` and for +the same reason: only the detail pane reads it, so indenting it costs every +visitor who opens a section. + +## Until it arrives + +There is no `data/grades.json` in the repo. `loadGrades` treats a 404 as "not +published yet" rather than as a failure — settled, never retried, and distinct +from the 500 that stays retryable — so the block does not render and nothing +else on the page changes. Everything above is written and tested against +fixtures in `tests/fixtures.js`. diff --git a/js/app.js b/js/app.js index b9a5481..4d442e2 100644 --- a/js/app.js +++ b/js/app.js @@ -2,6 +2,7 @@ import { fetchTerms, defaultTerm, searchAllPages, GEN_CATEGORIES, ApiError } fro import { filterCourses, parseQuery } from "./rank.js"; import { renderResults } from "./render.js"; import { loadRatings, loadRatingCourses, topRated, ratedCount, profileUrl, ratingsFailed } from "./ratings.js"; +import { loadGrades } from "./grades.js"; import { linkedTo, loadSeats, seatsTerm, seatsUpdated, seatsSectionCount, seatsFailed } from "./seats.js"; import { loadTrend } from "./trend.js"; import { renderDetail } from "./detail.js"; @@ -597,9 +598,9 @@ function applySelection(row) { showDetail(draw()); history.replaceState(null, "", link); - // Two files only the detail pane reads, fetched on the first section opened + // Three files only the detail pane reads, fetched on the first section opened // instead of at startup: the course codes behind "52 of 147 ratings are for CSE - // 2221", and which instructors have a photo. + // 2221", which instructors have a photo, and the grade curves. if (!detailFilesLanded) { // Memoised, so one attempt per page load: a missing snapshot will not appear // on the next click, and a loader that fails drops its own in-flight memo, so @@ -607,6 +608,8 @@ function applySelection(row) { detailFiles ??= Promise.all([ loadRatingCourses().catch((error) => console.warn("rating course codes unavailable", error)), loadHeadshots().catch((error) => console.warn("headshots unavailable", error)), + // Resolves to null, not an error, until the records request is fulfilled. + loadGrades().catch((error) => console.warn("grade curves unavailable", error)), ]); // Bound to this row rather than the first one opened, or a section selected // while the files were still in flight would never get its pane back. diff --git a/js/detail.js b/js/detail.js index 9f79916..3212719 100644 --- a/js/detail.js +++ b/js/detail.js @@ -7,6 +7,7 @@ import { ratingFor, searchUrl, profileUrl, ratingSpread, courseShare } from "./r import { faceFor, initials } from "./headshots.js"; import { linkedTo, seatsFor, seatsUpdated, unreachable } from "./seats.js"; import { trendFor } from "./trend.js"; +import { SCALE, gradesFor, gradesStatus, gradeSpread, aShare, gradesRange, withheldFor } from "./grades.js"; import { isIndividualStudy } from "./rank.js"; function el(tag, className, text) { @@ -232,6 +233,101 @@ function partners(title, numbers, term, entries) { return wrap; } +/** + * The eleven marks as one bar, A on the left. + * + * Built like spreadBar, but the segments carry a band rather than a score: + * eleven colours nobody can tell apart would be decoration, and what a student + * reads off this is how much of it is A, how much is the middle, and how much + * is E. + */ +function gradeBar({ counts, total }) { + const bar = el("div", "spread gspread"); + bar.setAttribute("role", "img"); + bar.setAttribute("aria-label", counts.map((n, i) => `${n} got ${SCALE[i]}`).filter((_, i) => counts[i] > 0).join(", ")); + counts.forEach((n, i) => { + if (!n) return; // a zero segment is a zero-width element with a tooltip nobody can reach + const segment = el("i", `g${i}`); + segment.style.width = `${(n / total) * 100}%`; + segment.title = `${n} got ${SCALE[i]}`; + bar.append(segment); + }); + return bar; +} + +/** + * What five years of the registrar's own records say about this instructor in + * this course. See docs/osu-grades.md. + * + * Only for a single named instructor, for the reason the rating block is: a + * curve averaged across two people is not either person's curve. + * + * Every branch here prints a different true sentence rather than a blank. A + * course whose sections were all too small to publish, a pass-fail course and a + * course nobody has a record for are three different things, and only the first + * two are worth saying out loud. + */ +function gradeBlock(person, course) { + const status = gradesStatus(person, course); + // Nothing to say: either the file is not published or it does not know this + // pairing. Neither is evidence about how anyone grades. + if (status === "unknown" || status === "unpublished") return null; + + const range = gradesRange(); + const wrap = block(range ? `Grades, ${range.first} to ${range.last}` : "Grades"); + + if (status === "withheld") { + const n = withheldFor(person, course); + wrap.append(el("p", "d-note", + `Ohio State withheld this curve: all ${n} of their sections were small enough that a distribution could identify someone.`)); + return wrap; + } + if (status === "ungraded") { + wrap.append(el("p", "d-note", "This course is graded satisfactory or unsatisfactory, so there is no curve to draw.")); + return wrap; + } + + const curve = gradesFor(person, course); + if (!curve) return null; + + const figs = el("div", "d-figs"); + figs.append(figure(curve.gpa.toFixed(2), "average GPA", "is-rating")); + figs.append(figure(`${Math.round(aShare(curve) * 100)}%`, "got A or A-")); + figs.append(figure(String(curve.n), "graded")); + wrap.append(figs); + + const spread = gradeSpread(curve); + if (spread) { + wrap.append(gradeBar(spread)); + wrap.append(el("div", "d-cap spread-cap", "A to E, left to right")); + } + + // Withdrawals are outside the mean by definition, which is exactly why they + // belong on screen: a course half the class drops has a flattering average. + if (curve.withdrew > 0) { + const share = Math.round((curve.withdrew / (curve.n + curve.withdrew)) * 100); + wrap.append(row("Withdrew", `${curve.withdrew} of ${curve.n + curve.withdrew} (${share}%)`, + share >= 15 ? "is-full" : null)); + } + + const terms = curve.terms === 1 ? "one term" : `${curve.terms} terms`; + const sections = curve.sections === 1 ? "one section" : `${curve.sections} sections`; + wrap.append(el("p", "d-note", `Across ${sections} over ${terms}.`)); + + if (curve.suppressed > 0) { + wrap.append(el("p", "d-note", + `${curve.suppressed} more ${curve.suppressed === 1 ? "section was" : "sections were"} too small to publish, so they are not in this curve.`)); + } + // One section is one cohort. The same professor teaching the same course to + // honours students and to a summer section produces two different curves, and + // a single one cannot be told from either. + if (curve.n < 30) { + wrap.append(el("p", "d-note", `Only ${curve.n} graded students, so treat this as thin evidence.`)); + } + + return wrap; +} + export function renderDetail({ section, course, term, entries, formatDate, shareUrl, scheduled = false, onSchedule }) { const wrap = document.createDocumentFragment(); const people = instructorsOf(section); @@ -271,6 +367,13 @@ export function renderDetail({ section, course, term, entries, formatDate, share wrap.append(el("p", "d-note", "No RateMyProfessors ratings. Their name links to a search.")); } + // Grades sit under the ratings because they answer the same question from the + // other side: what students said, then what the registrar recorded. + if (people.length === 1) { + const curve = gradeBlock(people[0], course); + if (curve) wrap.append(curve); + } + const flags = sectionFlags(section); if (flags.length) { const worth = block("Worth knowing"); diff --git a/js/grades.js b/js/grades.js new file mode 100644 index 0000000..ba5fcf8 --- /dev/null +++ b/js/grades.js @@ -0,0 +1,284 @@ +// Per-instructor grade distributions, from a five-year public records request. +// +// OSU publishes nothing like this. The file behind it arrived under R.C. 149.43 +// as a spreadsheet of one row per section, which scripts/fetch-grades.mjs folds +// into one curve per instructor per course. See docs/osu-grades.md for what the +// request covered and what it cannot answer. +// +// Loaded the way data/ratings-courses.json is: only the detail pane reads it, so +// it is fetched when the first section is opened rather than when the page loads. + +import { nameKey } from "./ratings.js"; + +// OSU's eleven graded marks and their points. There is no A+ and no D-; the +// registrar does not award either, so a column for them would never fill. +export const SCALE = ["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"]; +const POINTS = [4, 3.7, 3.3, 3, 2.7, 2.3, 2, 1.7, 1.3, 1, 0]; + +let index = null; +let loading = null; +let failed = false; +// A 404 is the ordinary state of this file until the records request is +// fulfilled, and it is settled rather than broken: nothing is coming, so +// nothing should ask again. Distinct from `failed` for the same reason seats.js +// separates "missing" from "unknown". +let absent = false; + +/** + * The join key, which is the local part of an OSU address: bucci.2@osu.edu is + * bucci.2. The class API already publishes this beside every instructor, and + * the records request asked for the same field, so the two sides meet on a + * string the registrar and the course search both issued. + * + * This is the one join in Finder that does not have to guess. Name matching is + * the fallback for rows that came back without an address, and it inherits + * ratings.js's refusal to choose between two people with one name. + */ +export function gradeKey(email) { + const at = String(email ?? "").indexOf("@"); + return at > 0 ? String(email).slice(0, at).toLowerCase() : ""; +} + +/** + * Load the snapshot. + * + * Resolves to the index, or to null when the file is not published yet. A real + * failure rejects and is not cached, so a caller may retry; an absent file is + * an answer and is remembered. + */ +export async function loadGrades(baseUrl = "data/grades.json") { + if (index || absent) return index; + if (loading) return loading; + + loading = (async () => { + const response = await fetch(baseUrl); + // Only 404 means unpublished. A 500 is a server having a bad day and must + // stay retryable, or one bad minute would turn the feature off for the tab. + if (response.status === 404) { + absent = true; + return null; + } + if (!response.ok) throw new Error(`grades ${response.status}`); + const data = await response.json(); + + const byKey = new Map(); + const byName = new Map(); + for (const [key, row] of Object.entries(data.instructors ?? {})) { + byKey.set(key.toLowerCase(), row); + const name = nameKey(row?.name ?? ""); + if (!name) continue; + if (!byName.has(name)) byName.set(name, []); + byName.get(name).push(row); + } + + index = { meta: data, byKey, byName }; + failed = false; + return index; + })().catch((error) => { + // A cached rejection would pin the failure for the life of the tab, exactly + // as in ratings.js, and every caller here swallows the error too. + loading = null; + failed = true; + throw error; + }); + + return loading; +} + +/** True once the snapshot was asked for and did not arrive. */ +export function gradesFailed() { + return failed; +} + +/** True once the snapshot came back 404, which is not a failure. */ +export function gradesAbsent() { + return absent; +} + +/** What the request covered, for labelling the curve as historical. */ +export function gradesRange(idx = index) { + const meta = idx?.meta; + return meta?.firstTerm && meta?.lastTerm + ? { first: meta.firstTerm, last: meta.lastTerm, terms: meta.termCount ?? null } + : null; +} + +/** + * Is this a row of counts at all? + * + * One rule, because the status and the mean have to agree about what a broken + * row is. They did not: a negative count has the right eleven columns, so + * checking only the length called a bad parse a pass-fail course. + */ +function gradedRow(counts) { + return Array.isArray(counts) + && counts.length === SCALE.length + && counts.every((n) => typeof n === "number" && n >= 0 && Number.isFinite(n)); +} + +/** + * Mean grade points over the marks that carry any, or null when none do. + * + * S, U, PA, NP, W and I are all outside this on purpose. None of them has a + * point value, so folding them in at zero would turn a course full of + * satisfactory marks into a course full of failures. + */ +export function gpaOf(counts) { + if (!gradedRow(counts)) return null; + + let students = 0; + let points = 0; + for (let i = 0; i < counts.length; i++) { + students += counts[i]; + points += counts[i] * POINTS[i]; + } + return students > 0 ? { n: students, gpa: points / students } : null; +} + +/** + * The share of graded students who got an A or an A-. + * + * Kept separate from the mean because the two disagree in the case students + * most want to spot: a section that is half A and half E averages the same as + * a section that is entirely B, and only one of them is a coin flip. + */ +export function aShare(curve) { + if (!curve?.n) return null; + return (curve.counts[0] + curve.counts[1]) / curve.n; +} + +/** Counts and their total for a bar, or null when there is nothing to draw. Mirrors ratingSpread. */ +export function gradeSpread(curve) { + if (!curve?.n) return null; + return { counts: curve.counts, total: curve.n }; +} + +function curveFrom(row, meta) { + const counts = row?.counts; + const summed = gpaOf(counts); + if (!summed) return null; + + const other = row.other ?? {}; + return { + counts, + n: summed.n, + gpa: summed.gpa, + withdrew: other.W ?? 0, + // S/U and PA/NP are a pass-fail enrolment, not a grade anyone can compare. + ungraded: (other.S ?? 0) + (other.U ?? 0) + (other.PA ?? 0) + (other.NP ?? 0), + incomplete: other.I ?? 0, + sections: row.sections ?? 0, + terms: row.terms ?? 0, + // Sections the registrar withheld under the small-enrolment rule. Held on + // the curve rather than dropped, because a curve missing its smallest + // sections is a different claim from a complete one, and only this number + // says which of the two is on screen. + suppressed: row.suppressed ?? 0, + ...(meta ? { instructor: meta } : {}), + }; +} + +/** + * One instructor's curve in one course, or null. + * + * Null covers every way this can be unknown: the file is not loaded, not + * published, lists nobody by that key, or lists them without this course. None + * of those is evidence that a professor grades any particular way, so none of + * them may render as a number. + */ +export function gradesFor(person, course, idx = index) { + const row = instructorRow(person, idx); + if (!row) return null; + + // Exact course match only. The registrar issued both the subject and the + // catalog number, so unlike the free text students type into RMP there is + // nothing here to normalise, and 1110.01 really is not 1110.02. + const subject = String(course?.subject ?? "").toUpperCase(); + const number = String(course?.catalogNumber ?? "").toUpperCase(); + if (!subject || !number) return null; + + return curveFrom(row.courses?.[`${subject} ${number}`], null); +} + +/** + * What is known about one instructor's curve in one course, without waiting. + * + * "ready" a curve exists and gradesFor will answer + * "withheld" the registrar published nothing, every section being too small + * "ungraded" students finished, but on marks that carry no grade points + * "unknown" no row for this instructor, or the file is not loaded yet + * "unpublished" the records request is not fulfilled, so there is no file + * + * The middle two exist because the pane has three different true sentences to + * say and gradesFor returns null for all three. "Every section was too small to + * publish" is not "this professor has no record", and neither is "this course is + * pass-fail". Printing the same blank for all of them would invent an absence. + */ +export function gradesStatus(person, course, idx = index) { + if (absent) return "unpublished"; + if (!idx) return "unknown"; + + const row = instructorRow(person, idx); + if (!row) return "unknown"; + + const subject = String(course?.subject ?? "").toUpperCase(); + const number = String(course?.catalogNumber ?? "").toUpperCase(); + const entry = subject && number ? row.courses?.[`${subject} ${number}`] : null; + if (!entry) return "unknown"; + + if (curveFrom(entry, null)) return "ready"; + // A row that parses but grades nobody is one of the two honest absences. The + // suppression count is what tells them apart. + if (!gradedRow(entry.counts)) return "unknown"; + return (entry.suppressed ?? 0) > 0 ? "withheld" : "ungraded"; +} + +/** How many of a course's sections the registrar withheld, whether or not a curve survived. */ +export function withheldFor(person, course, idx = index) { + const row = instructorRow(person, idx); + const subject = String(course?.subject ?? "").toUpperCase(); + const number = String(course?.catalogNumber ?? "").toUpperCase(); + const entry = subject && number ? row?.courses?.[`${subject} ${number}`] : null; + return entry?.suppressed ?? 0; +} + +/** + * Everything one instructor taught, newest-heaviest first, for the case where + * they have a record but not in this course. + */ +export function gradesOverall(person, idx = index) { + const row = instructorRow(person, idx); + const courses = Object.entries(row?.courses ?? {}); + if (!courses.length) return null; + + const total = new Array(SCALE.length).fill(0); + let sections = 0; + let suppressed = 0; + for (const [, entry] of courses) { + if (!Array.isArray(entry?.counts) || entry.counts.length !== SCALE.length) continue; + for (let i = 0; i < total.length; i++) total[i] += entry.counts[i]; + sections += entry.sections ?? 0; + suppressed += entry.suppressed ?? 0; + } + return curveFrom({ counts: total, sections, suppressed, terms: row.terms ?? 0 }, null); +} + +/** + * Find one instructor's row. + * + * The address is exact and settles it. Falling back to a name repeats + * ratingFor's rule rather than a looser one: two people under a single name + * return nothing, because a wrong curve is worse than no curve, and a curve + * carries more authority than a rating does precisely because it came from the + * registrar. + */ +function instructorRow(person, idx = index) { + if (!idx) return null; + + const key = gradeKey(person?.email); + const exact = key ? idx.byKey.get(key) : null; + if (exact) return exact; + + const named = idx.byName.get(nameKey(person?.name ?? "")) ?? []; + return named.length === 1 ? named[0] : null; +} diff --git a/scripts/fetch-grades.mjs b/scripts/fetch-grades.mjs new file mode 100644 index 0000000..ae97964 --- /dev/null +++ b/scripts/fetch-grades.mjs @@ -0,0 +1,446 @@ +// Fold the public records grade spreadsheet into data/grades.json. +// +// Unlike the other three snapshotters this one fetches nothing. The records +// office sends a file once, under R.C. 149.43, and this reads it off disk: +// +// node scripts/fetch-grades.mjs records.csv +// +// The columns are whatever the registrar's export tool named them, which is not +// known until the file arrives. So every field is resolved through a table of +// spellings and an unresolved one aborts the run printing the headers it did +// see, rather than guessing which column holds the B minuses. Add the spelling +// to HEADERS when you meet it; that is the whole adaptation step. +// +// XLSX is not read here. Save it as CSV first, which loses nothing: this reads +// one sheet of flat rows. + +import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { countRefusal, residueRefusal, refusalMessage, fatal } from "./guards.mjs"; + +const OUT_PATH = "data/grades.json"; + +// Columbus only, matching the campus the site searches. Regional campuses are +// separate schools on RateMyProfessors and a separate catalog here, so folding +// them together would put a Newark section under a Columbus professor's curve. +const CAMPUS = process.env.GRADES_CAMPUS ?? "columbus"; + +// The eleven graded marks, in the order js/grades.js reads them. +export const SCALE = ["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"]; + +// Marks that finish a course without carrying grade points, kept apart from the +// curve but not thrown away: a course that is 80% withdrawals is telling you +// something the mean of the survivors is not. +export const OTHER = ["W", "S", "U", "PA", "NP", "I"]; + +// At least this many instructors, or the parse found a file it did not +// understand. Five years of Columbus sections is tens of thousands of rows +// across a few thousand people; 200 is a floor, not an expectation. +const MIN_INSTRUCTORS = 200; + +// A tenth of rows failing is a column that moved, not a few odd lines. +const MAX_RESIDUE = 0.1; + +/** + * Header spellings, canonical field first. Matching is case-insensitive and + * ignores everything that is not a letter or a digit, so "Catalog Nbr", + * "catalog_nbr" and "CATALOGNBR" are one spelling and only genuinely different + * words need a new entry. + */ +export const HEADERS = { + term: ["term", "termcode", "strm", "semester", "termdescr", "academicterm"], + subject: ["subject", "subjectcode", "subj", "coursesubject"], + catalog: ["catalog", "catalognumber", "catalognbr", "coursenumber", "catalogno"], + classNumber: ["classnumber", "classnbr", "classno", "classid"], + section: ["section", "sectionnumber", "classsection", "sectionno"], + campus: ["campus", "campuscode", "campusdescr", "location"], + // "name.#" slugs to "name", which is also what an instructor-name column + // slugs to, so it is deliberately not an alias here: resolving a column of + // people's names as addresses would key every curve on a display name. + instructorEmail: ["instructoremail", "email", "emailaddress", "instructoremailaddress", "primaryinstructoremail", "instructorusername", "osuusername", "username"], + instructorName: ["instructorname", "instructor", "primaryinstructor", "primaryinstructorname", "teacher"], + title: ["coursetitle", "title", "classtitle", "coursedescr"], + enrolled: ["enrolled", "totalenrolled", "enrollmenttotal", "totalstudents", "n"], +}; + +// Grade columns, which the export may name bare ("A-") or prefixed ("Grade A-", +// "CNT_A_MINUS"). The bare form is tried first so a file that uses it is never +// reinterpreted. +const GRADE_ALIASES = { + "A": ["a", "gradea", "cnta", "counta", "numa"], + "A-": ["a-", "aminus", "gradeaminus", "gradea-", "cntaminus"], + "B+": ["b+", "bplus", "gradebplus", "gradeb+", "cntbplus"], + "B": ["b", "gradeb", "cntb", "countb", "numb"], + "B-": ["b-", "bminus", "gradebminus", "gradeb-", "cntbminus"], + "C+": ["c+", "cplus", "gradecplus", "gradec+", "cntcplus"], + "C": ["c", "gradec", "cntc", "countc", "numc"], + "C-": ["c-", "cminus", "gradecminus", "gradec-", "cntcminus"], + "D+": ["d+", "dplus", "gradedplus", "graded+", "cntdplus"], + "D": ["d", "graded", "cntd", "countd", "numd"], + // OSU's failing mark is E. Some exports still label the column F, and a file + // carrying both is a file with two failing columns, which is summed, not picked. + "E": ["e", "f", "ef", "e f", "gradee", "gradef", "cnte", "cntf"], +}; + +const OTHER_ALIASES = { + W: ["w", "withdrawn", "withdrew", "gradew", "cntw"], + S: ["s", "satisfactory", "grades", "cnts"], + U: ["u", "unsatisfactory", "gradeu", "cntu"], + PA: ["pa", "p", "pass", "gradepa"], + NP: ["np", "nopass", "notpass", "gradenp"], + I: ["i", "inc", "incomplete", "gradei", "cnti"], +}; + +/** Normalise a field name the way the alias tables are written. */ +export const slug = (text) => String(text ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** + * The same, for grade columns, keeping the plus and the minus. + * + * slug() cannot be used on these: it turns "A-" into "a", which is also what it + * turns "A" into, so both columns resolve to whichever came first and every + * A-minus in five years gets read out of the A column. The sign is the whole + * distinction here, so it is the one character that survives normalising. + */ +export const markSlug = (text) => String(text ?? "").toLowerCase().replace(/[^a-z0-9+-]/g, ""); + +/** + * A CSV reader that respects quotes. + * + * Course titles carry commas ("Software I: Components, Interfaces") and an + * instructor column can hold "Bucci, Paolo", so splitting on commas loses a + * column exactly where it is least visible. Handles the doubled quote escape + * and both line endings. + */ +export function parseCsv(text) { + const rows = []; + let row = []; + let field = ""; + let quoted = false; + + const body = String(text ?? "").replace(/^/, ""); // Excel writes a BOM + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (quoted) { + if (ch !== '"') { field += ch; continue; } + if (body[i + 1] === '"') { field += '"'; i++; continue; } + quoted = false; + continue; + } + if (ch === '"') { quoted = true; continue; } + if (ch === ",") { row.push(field); field = ""; continue; } + if (ch === "\r") continue; + if (ch === "\n") { row.push(field); rows.push(row); row = []; field = ""; continue; } + field += ch; + } + if (field !== "" || row.length) { row.push(field); rows.push(row); } + // A trailing newline leaves one empty row, and so does a blank line anywhere. + return rows.filter((r) => r.some((cell) => cell.trim() !== "")); +} + +/** + * Map canonical field names onto column indexes. + * + * Returns the mapping and whatever could not be resolved. The caller decides + * which absences are fatal, because only some are: a missing B- column breaks + * every curve, a missing course title breaks nothing. + */ +export function resolveHeaders(header) { + // Two indexes over the same header row: field names normalise punctuation + // away so "catalog_nbr" and "Catalog Nbr" are one spelling, and grade columns + // keep their sign so "B+" and "B" stay two columns. + const seen = new Map(); + const marks = new Map(); + header.forEach((name, i) => { + // First column wins. A duplicate header is usually a second export of the + // same field, and picking the later one silently changes which. + const key = slug(name); + if (key && !seen.has(key)) seen.set(key, i); + const mark = markSlug(name); + if (mark && !marks.has(mark)) marks.set(mark, i); + }); + + const lookup = (index, normalise) => (aliases) => { + for (const alias of aliases) { + const at = index.get(normalise(alias)); + if (at != null) return at; + } + return null; + }; + const find = lookup(seen, slug); + const findMark = lookup(marks, markSlug); + + const fields = {}; + for (const [name, aliases] of Object.entries(HEADERS)) fields[name] = find(aliases); + + const grades = {}; + for (const [mark, aliases] of Object.entries(GRADE_ALIASES)) grades[mark] = findMark(aliases); + + const other = {}; + for (const [mark, aliases] of Object.entries(OTHER_ALIASES)) other[mark] = findMark(aliases); + + const missing = [ + ...["term", "subject", "catalog"].filter((f) => fields[f] == null), + ...SCALE.filter((mark) => grades[mark] == null).map((mark) => `grade ${mark}`), + ]; + // Either identifier will do. Only having neither leaves a row that cannot be + // attributed to anyone. + if (fields.instructorEmail == null && fields.instructorName == null) missing.push("instructor email or name"); + + return { fields, grades, other, missing, header }; +} + +/** + * A cell that is a count, or null when it is not. + * + * A withheld cell is the point of this: the registrar blanks it, or writes a + * marker, and neither is a zero. Returning null keeps the difference, and the + * caller turns it into a suppressed section rather than an empty curve. + */ +export function count(cell) { + const text = String(cell ?? "").trim(); + if (text === "") return null; + // The markers a redaction is written as. "*" and "<5" and friends all mean + // the same thing: there was a number here and you may not have it. + if (/^(\*+|n\/?a|null|-{1,2}|redacted|suppressed|<\s*\d+)$/i.test(text)) return null; + const n = Number(text.replace(/,/g, "")); + return Number.isFinite(n) && n >= 0 ? Math.round(n) : null; +} + +/** Is this row's campus the one being kept? Rows with no campus column are kept. */ +function onCampus(value, at) { + if (at == null) return true; + const text = slug(value); + if (!text) return true; + const want = slug(CAMPUS); + // Exports write it as a code ("COL"), a name ("Columbus") or both. + return text === want || text.startsWith(want.slice(0, 3)) || want.startsWith(text); +} + +/** Sort key for a term, from either a code (1218) or a name (Autumn 2021). */ +export function termRank(term) { + const text = String(term ?? "").trim(); + if (/^\d{4}$/.test(text)) return Number(text); + + const year = /(\d{4})/.exec(text); + if (!year) return 0; + const season = /spring/i.test(text) ? 2 : /summer/i.test(text) ? 4 : /aut|fall/i.test(text) ? 8 : 0; + // Autumn 2021 is term 1218: the century digit, the last two of the year, and + // the season. Reproducing that keeps names and codes on one scale. + return 1000 + (Number(year[1]) % 100) * 10 + season; +} + +/** + * Fold parsed rows into the per-instructor, per-course shape the site reads. + * + * A section whose grade cells are all withheld counts as suppressed rather than + * as a section of zero students, which is the one place this file can quietly + * lie about a professor. + */ +export function aggregate(rows) { + const people = new Map(); + const terms = new Set(); + + for (const row of rows) { + terms.add(row.term); + let person = people.get(row.key); + if (!person) { + person = { name: row.name, courses: new Map(), terms: new Set() }; + people.set(row.key, person); + } + // Names drift across five years of exports; the longest is the most + // complete, and a middle name helps the fallback match. + if (row.name && row.name.length > (person.name?.length ?? 0)) person.name = row.name; + person.terms.add(row.term); + + const code = `${row.subject} ${row.catalog}`; + let entry = person.courses.get(code); + if (!entry) { + entry = { counts: new Array(SCALE.length).fill(0), other: {}, sections: 0, terms: new Set(), suppressed: 0 }; + person.courses.set(code, entry); + } + entry.sections++; + entry.terms.add(row.term); + + if (row.suppressed) { + entry.suppressed++; + continue; + } + for (let i = 0; i < SCALE.length; i++) entry.counts[i] += row.counts[i]; + for (const mark of OTHER) { + if (row.other[mark]) entry.other[mark] = (entry.other[mark] ?? 0) + row.other[mark]; + } + } + + const instructors = {}; + for (const [key, person] of [...people].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + const courses = {}; + for (const [code, entry] of [...person.courses].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + courses[code] = { + counts: entry.counts, + other: entry.other, + sections: entry.sections, + terms: entry.terms.size, + suppressed: entry.suppressed, + }; + } + instructors[key] = { name: person.name, terms: person.terms.size, courses }; + } + + const ordered = [...terms].sort((a, b) => termRank(a) - termRank(b)); + return { instructors, terms: ordered }; +} + +/** + * One spreadsheet row, or null with a reason when it cannot be read. + * + * The instructor key is the address's local part where there is one, which is + * what js/grades.js joins on. A row with only a name still counts: the site + * falls back to name matching, and dropping those would lose whole departments + * if the office redacts the email column. + */ +export function readRow(cells, map) { + const at = (index) => (index == null ? "" : String(cells[index] ?? "").trim()); + const { fields, grades, other } = map; + + const subject = at(fields.subject).toUpperCase(); + const catalog = at(fields.catalog).toUpperCase(); + const term = at(fields.term); + if (!subject || !catalog || !term) return { skip: "no course or term" }; + if (!onCampus(at(fields.campus), fields.campus)) return { skip: "another campus" }; + + const email = at(fields.instructorEmail).toLowerCase(); + const name = at(fields.instructorName); + const local = email.includes("@") ? email.slice(0, email.indexOf("@")) : email; + // A bare username in the email column is the same identifier without the host. + const key = local || slug(name); + if (!key) return { skip: "no instructor" }; + // "Staff" is not a person, and folding five years of unstaffed sections into + // one curve would invent the most prolific instructor at the university. + if (/^(staff|tba|tbd|notassigned|unassigned)$/.test(slug(key))) return { skip: "unstaffed" }; + + const counts = SCALE.map((mark) => count(cells[grades[mark]])); + const others = {}; + for (const mark of OTHER) { + if (other[mark] == null) continue; + const n = count(cells[other[mark]]); + if (n) others[mark] = n; + } + + // Every graded column withheld is a suppressed section. Some withheld and + // some not is a partial redaction, and summing the rest would publish a curve + // whose total contradicts the enrolment beside it, so it is suppressed too. + const suppressed = counts.some((n) => n == null); + + return { + term, + subject, + catalog, + key, + name: name || null, + suppressed, + counts: suppressed ? new Array(SCALE.length).fill(0) : counts, + other: suppressed ? {} : others, + }; +} + +export function buildSnapshot(text, { minInstructors = MIN_INSTRUCTORS, previous = 0 } = {}) { + const rows = parseCsv(text); + if (rows.length < 2) return { refusals: [fatal("grades: the file has no rows under its header")] }; + + const map = resolveHeaders(rows[0]); + if (map.missing.length) { + return { + refusals: [fatal( + `grades: could not find ${map.missing.join(", ")}.\n` + + `The file's headers are: ${rows[0].join(" | ")}\n` + + "Add the spelling to HEADERS or GRADE_ALIASES in scripts/fetch-grades.mjs." + )], + }; + } + + const parsed = []; + const skipped = new Map(); + let failed = 0; + for (const cells of rows.slice(1)) { + const row = readRow(cells, map); + if (row.skip) { + skipped.set(row.skip, (skipped.get(row.skip) ?? 0) + 1); + // Another campus is a row this run is not interested in, which is not a + // row it failed to read. Only the rest count against the residue rate. + if (row.skip !== "another campus" && row.skip !== "unstaffed") failed++; + continue; + } + parsed.push(row); + } + + const { instructors, terms } = aggregate(parsed); + const total = Object.keys(instructors).length; + + return { + skipped, + parsed: parsed.length, + refusals: [ + residueRefusal("grades rows", parsed.length, failed, MAX_RESIDUE), + countRefusal("grades instructors", total, minInstructors, previous), + ], + snapshot: { + source: "The Ohio State University, public records request under R.C. 149.43", + campus: CAMPUS, + firstTerm: terms[0] ?? null, + lastTerm: terms[terms.length - 1] ?? null, + termCount: terms.length, + scale: SCALE, + count: total, + instructors, + }, + }; +} + +/** The instructor count already committed, or 0 when there is no file yet. */ +export async function previousCount(path = OUT_PATH) { + try { + return JSON.parse(await readFile(path, "utf8")).count ?? 0; + } catch { + return 0; // no file yet, which is the ordinary first run + } +} + +async function main() { + const input = process.argv[2]; + if (!input) { + console.error("usage: node scripts/fetch-grades.mjs "); + process.exit(1); + } + + const built = buildSnapshot(await readFile(input, "utf8"), { previous: await previousCount() }); + if (built.snapshot) { + console.log(`read ${built.parsed} section rows into ${built.snapshot.count} instructors`); + for (const [reason, n] of built.skipped ?? []) console.log(` skipped ${n}: ${reason}`); + } + + const refusal = refusalMessage(built.refusals); + if (refusal) { + console.error(`Refusing to write ${OUT_PATH}.\n${refusal}`); + process.exit(1); + } + + // Compact, like ratings-courses.json and for the same reason: only the detail + // pane reads it, and indenting it costs every visitor who opens a section. + const json = JSON.stringify(built.snapshot, null, 0) + "\n"; + await mkdir(dirname(OUT_PATH), { recursive: true }); + const tmp = `${OUT_PATH}.tmp`; + await writeFile(tmp, json, "utf8"); + await rename(tmp, OUT_PATH); // atomic, so a crash cannot leave half a file + + console.log(`Wrote ${built.snapshot.count} instructors to ${OUT_PATH} (${json.length} bytes)`); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + console.error(`fetch-grades failed: ${error.message}`); + process.exit(1); + }); +} diff --git a/tests/detail-grades-absent.test.js b/tests/detail-grades-absent.test.js new file mode 100644 index 0000000..c7e6c39 --- /dev/null +++ b/tests/detail-grades-absent.test.js @@ -0,0 +1,54 @@ +// The state the grade feature ships in: data/grades.json does not exist yet, so +// the pane must draw exactly what it drew before the feature landed. +// +// Its own file because module state is what is under test. A query string on +// js/detail.js does not reach js/grades.js underneath it, so the only way to +// render a pane against an unloaded grade module is a process that never loads +// one. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { renderDetail } from "../js/detail.js"; +import { gradesAbsent, gradesStatus } from "../js/grades.js"; +import { meeting, section } from "./fixtures.js"; +import { setupDom } from "./dom.js"; +import { withGrades, withRatings, withSeats } from "./helpers.js"; + +setupDom(); +await withSeats(["1268"]); +await withRatings(); +// The 404 the site really gets today. +await withGrades(null); + +const CSE2221 = { subject: "CSE", catalogNumber: "2221", title: "Software I", minUnits: 4, maxUnits: 4 }; +const BUCCI = section("1001", { + meetings: [meeting(["monday", "wednesday", "friday"], "09:10", "10:05", + [{ displayName: "Paolo Bucci", email: "bucci.2@osu.edu", role: "PI" }])], +}); + +const host = document.createElement("div"); +host.append(renderDetail({ section: BUCCI, course: CSE2221, term: "1268", entries: [] })); + +test("the file reads as unpublished rather than as a failure", () => { + assert.equal(gradesAbsent(), true); + assert.equal(gradesStatus({ name: "Paolo Bucci", email: "bucci.2@osu.edu" }, CSE2221), "unpublished"); +}); + +test("no grades block is drawn", () => { + const found = host.querySelectorAll(".d-block") + .find((b) => b.querySelector(".eyebrow")?.textContent?.startsWith("Grades")); + assert.equal(found, undefined); +}); + +test("the rest of the pane is exactly as it was", () => { + assert.equal(host.querySelector(".d-name").textContent, "Paolo Bucci"); + const blocks = host.querySelectorAll(".d-block") + .map((b) => b.querySelector(".eyebrow")?.textContent); + assert.ok(blocks.includes("Seats")); + assert.ok(blocks.includes("Meets")); + // The rating figures still lead the pane, since only the grade block moved. + assert.ok(host.querySelector(".d-figs")); + assert.ok(host.querySelector(".spread"), "the rating spread is untouched"); + assert.equal(host.querySelector(".gspread"), null); +}); diff --git a/tests/detail-grades.test.js b/tests/detail-grades.test.js new file mode 100644 index 0000000..4d24c8e --- /dev/null +++ b/tests/detail-grades.test.js @@ -0,0 +1,138 @@ +// The grade curve in the right pane. Like the rest of js/detail.js this fetches +// nothing: the loaders have already put the fixture into the real module. +// +// What these pin is mostly what the pane refuses to draw. A curve carries the +// registrar's authority, so every way it can be absent has to read as absent +// rather than as a number. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { renderDetail } from "../js/detail.js"; +import { entry, person, meeting, section, taught, SEATS_TERMS } from "./fixtures.js"; +import { setupDom } from "./dom.js"; +import { withGrades, withRatings, withSeats } from "./helpers.js"; + +setupDom(); +await withSeats(["1268"]); +await withRatings(); +await withGrades(); + +const MWF = ["monday", "wednesday", "friday"]; +const CSE2221 = { subject: "CSE", catalogNumber: "2221", title: "Software I", minUnits: 4, maxUnits: 4 }; + +/** A section taught by one instructor with an OSU address, the ordinary case. */ +function bySomeone(name, email, classNumber = "1001") { + return section(classNumber, { + meetings: [meeting(MWF, "09:10", "10:05", [{ displayName: name, email, role: "PI" }])], + }); +} + +function pane(target, course = CSE2221) { + const host = document.createElement("div"); + host.append(renderDetail({ section: target, course, term: "1268", entries: [] })); + return host; +} + +/** The grades block, or null when the pane drew none. */ +function gradeBlock(host) { + return host.querySelectorAll(".d-block") + .find((b) => b.querySelector(".eyebrow")?.textContent?.startsWith("Grades")) ?? null; +} + +const notes = (block) => block.querySelectorAll(".d-note").map((n) => n.textContent); +const figures = (block) => block.querySelectorAll(".d-fig").map((f) => [ + f.querySelector(".d-num").textContent, + f.querySelector(".d-cap").textContent, +]); + +test("the block is headed with the years the records cover", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + assert.equal(block.querySelector(".eyebrow").textContent, "Grades, Autumn 2021 to Spring 2026"); +}); + +test("the figures lead with the mean and the share who got an A", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + assert.deepEqual(figures(block), [ + ["2.77", "average GPA"], + ["35%", "got A or A-"], + ["860", "graded"], + ]); +}); + +test("the curve draws eleven marks, A on the left", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + const bar = block.querySelector(".gspread"); + const segments = bar.querySelectorAll("i"); + assert.equal(segments.length, 11); + assert.equal(segments[0].className, "g0"); + assert.equal(segments[0].title, "180 got A"); + assert.equal(segments[10].title, "90 got E"); + // Spoken as well as drawn, the way the rating spread is. + assert.match(bar.getAttribute("aria-label"), /^180 got A, 120 got A-/); +}); + +test("withdrawals sit beside the curve, not inside it", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + const row = block.querySelectorAll(".d-row") + .find((line) => line.textContent.startsWith("Withdrew")); + // 61 of 921, which is 7%: the 860 in the mean plus the 61 that cannot be. + assert.match(row.textContent, /61 of 921 \(7%\)/); +}); + +test("the sample size rides along with the curve", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + assert.ok(notes(block).some((n) => n === "Across 22 sections over 9 terms.")); +}); + +test("sections the registrar withheld are named, not silently missing", () => { + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"))); + assert.ok(notes(block).some((n) => n.includes("2 more sections were too small to publish"))); +}); + +test("a course whose every section was withheld says so instead of vanishing", () => { + const block = gradeBlock(pane(bySomeone("Wes Fenwick", "fenwick.9@osu.edu"), { subject: "CSE", catalogNumber: "5911" })); + assert.equal(block.querySelector(".gspread"), null, "there is no curve to draw"); + assert.match(notes(block)[0], /withheld this curve: all 3 of their sections were small enough/); +}); + +test("a pass-fail course says that rather than drawing an empty curve", () => { + const block = gradeBlock(pane(bySomeone("Ada Nkemelu", "nkemelu.4@osu.edu"), { subject: "CSE", catalogNumber: "4998" })); + assert.equal(block.querySelector(".gspread"), null); + assert.match(notes(block)[0], /graded satisfactory or unsatisfactory/); +}); + +test("a thin curve says how thin", () => { + const block = gradeBlock(pane(bySomeone("Nora Whitfield", "whitfield.2@osu.edu"), { subject: "CSE", catalogNumber: "3241" })); + assert.ok(notes(block).some((n) => n === "Only 8 graded students, so treat this as thin evidence.")); +}); + +test("an instructor with no record draws no block at all", () => { + assert.equal(gradeBlock(pane(bySomeone("Someone Unknown", "nobody.4@osu.edu"))), null); +}); + +test("a record in another course is not a record in this one", () => { + // Bucci has a curve, but not in MATH 1151. + const block = gradeBlock(pane(bySomeone("Paolo Bucci", "bucci.2@osu.edu"), { subject: "MATH", catalogNumber: "1151" })); + assert.equal(block, null); +}); + +test("a co-taught section gets no curve, the way it gets no rating", () => { + const shared = section("1001", { + meetings: [meeting(MWF, "09:10", "10:05", [ + { displayName: "Paolo Bucci", email: "bucci.2@osu.edu", role: "PI" }, + { displayName: "Diana Kline", email: "kline.1@osu.edu", role: "PI" }, + ])], + }); + assert.equal(gradeBlock(pane(shared)), null, "an average across two people is not either person's"); +}); + +test("a section with no instructor draws no curve", () => { + assert.equal(gradeBlock(pane(section("1001", { meetings: [meeting(MWF, "09:10", "10:05", [])] }))), null); +}); + +test("a name finds the curve when the section carries no address", () => { + const noEmail = taught("1001", MWF, "09:10", "10:05", ["Diana Ikenberry Kline"]); + const block = gradeBlock(pane(noEmail, { subject: "MATH", catalogNumber: "1151" })); + assert.equal(figures(block)[2][0], "104"); +}); diff --git a/tests/fetch-grades.test.js b/tests/fetch-grades.test.js new file mode 100644 index 0000000..6c640c0 --- /dev/null +++ b/tests/fetch-grades.test.js @@ -0,0 +1,253 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseCsv, resolveHeaders, count, termRank, readRow, buildSnapshot, slug } from "../scripts/fetch-grades.mjs"; + +// The header the request asked for, in one plausible spelling. Every test that +// does not care about headers reuses it. +const HEADER = "Term,Subject,Catalog Nbr,Class Nbr,Section,Campus,Instructor Email,Instructor Name,A,A-,B+,B,B-,C+,C,C-,D+,D,E,W,I"; + +/** One CSV of the standard header plus the given body lines. */ +const file = (...lines) => [HEADER, ...lines].join("\n"); + +const built = (...lines) => buildSnapshot(file(...lines), { minInstructors: 1 }); + +test("parseCsv keeps a comma inside quotes", () => { + const rows = parseCsv('a,b,c\n1,"Components, Interfaces",3\n'); + assert.deepEqual(rows[1], ["1", "Components, Interfaces", "3"]); +}); + +test("parseCsv handles a doubled quote, CRLF and a BOM", () => { + const rows = parseCsv('a,b\r\n1,"she said ""hi"""\r\n'); + assert.deepEqual(rows[0], ["a", "b"]); + assert.deepEqual(rows[1], ["1", 'she said "hi"']); +}); + +test("parseCsv drops blank lines rather than reading them as rows", () => { + assert.equal(parseCsv("a,b\n\n1,2\n\n").length, 2); +}); + +test("headers resolve however the export spelled them", () => { + const spellings = ["Catalog Nbr", "catalog_nbr", "CATALOGNBR", "Catalog Number"]; + for (const spelling of spellings) { + const map = resolveHeaders(["Term", "Subject", spelling, "Instructor Email", ...["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"]]); + assert.equal(map.missing.length, 0, spelling); + assert.equal(map.fields.catalog, 2, spelling); + } +}); + +test("a missing grade column is named, not guessed around", () => { + const map = resolveHeaders(["Term", "Subject", "Catalog", "Instructor Email", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D"]); + assert.deepEqual(map.missing, ["grade E"]); +}); + +test("either instructor identifier will do, but not neither", () => { + const base = ["Term", "Subject", "Catalog", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"]; + assert.equal(resolveHeaders([...base, "Instructor Email"]).missing.length, 0); + assert.equal(resolveHeaders([...base, "Instructor Name"]).missing.length, 0); + assert.deepEqual(resolveHeaders(base).missing, ["instructor email or name"]); +}); + +test("an unreadable header aborts and prints what the file actually had", () => { + const result = buildSnapshot("Semester,Dept,Course,Prof\n2021,CSE,2221,Bucci\n"); + assert.ok(result.refusals[0].reason.includes("Semester,Dept,Course,Prof".replaceAll(",", " | "))); + assert.ok(result.refusals[0].reason.includes("grade A")); + assert.equal(result.refusals[0].forceable, false, "a file nobody can read is not forceable"); +}); + +test("count reads a number and refuses the ways a cell can be withheld", () => { + assert.equal(count("42"), 42); + assert.equal(count("1,204"), 1204); + assert.equal(count(" 7 "), 7); + for (const marker of ["", "*", "***", "N/A", "n/a", "--", "REDACTED", "suppressed", "<5", "< 10"]) { + assert.equal(count(marker), null, marker); + } +}); + +test("count refuses a negative and a non-number", () => { + assert.equal(count("-3"), null); + assert.equal(count("many"), null); +}); + +test("termRank orders codes and names on one scale", () => { + assert.ok(termRank("1218") < termRank("1262")); + assert.ok(termRank("Autumn 2021") < termRank("Spring 2026")); + assert.ok(termRank("Spring 2022") < termRank("Summer 2022")); + assert.ok(termRank("Summer 2022") < termRank("Autumn 2022")); + // A name and the code for the same term land on the same number. + assert.equal(termRank("Autumn 2021"), termRank("1218")); +}); + +test("a row folds into a curve keyed on the address", () => { + const out = built("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,10,5,4,6,2,1,3,0,0,1,2,3,1"); + const person = out.snapshot.instructors["bucci.2"]; + assert.equal(person.name, "Paolo Bucci"); + assert.deepEqual(person.courses["CSE 2221"].counts, [10, 5, 4, 6, 2, 1, 3, 0, 0, 1, 2]); + assert.deepEqual(person.courses["CSE 2221"].other, { W: 3, I: 1 }); + assert.equal(person.courses["CSE 2221"].sections, 1); +}); + +test("a bare username is the same key as the full address", () => { + const out = built( + "1218,CSE,2221,5168,0010,Columbus,bucci.2,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0", + "1222,CSE,2221,5169,0020,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0" + ); + assert.deepEqual(Object.keys(out.snapshot.instructors), ["bucci.2"]); + assert.equal(out.snapshot.instructors["bucci.2"].courses["CSE 2221"].sections, 2); +}); + +test("sections add up across terms, and terms are counted distinctly", () => { + const out = built( + "1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,10,0,0,0,0,0,0,0,0,0,0,0,0", + "1218,CSE,2221,5169,0020,Columbus,bucci.2@osu.edu,Paolo Bucci,5,0,0,0,0,0,0,0,0,0,0,0,0", + "1222,CSE,2221,5170,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,5,0,0,0,0,0,0,0,0,0,0,0,0" + ); + const entry = out.snapshot.instructors["bucci.2"].courses["CSE 2221"]; + assert.equal(entry.counts[0], 20); + assert.equal(entry.sections, 3); + assert.equal(entry.terms, 2); +}); + +test("a withheld section is counted as withheld, never as a section of nobody", () => { + const out = built( + "1218,CSE,5911,5168,0010,Columbus,fenwick.9@osu.edu,Wes Fenwick,10,2,0,0,0,0,0,0,0,0,0,0,0", + "1222,CSE,5911,5169,0010,Columbus,fenwick.9@osu.edu,Wes Fenwick,*,*,*,*,*,*,*,*,*,*,*,," + ); + const entry = out.snapshot.instructors["fenwick.9"].courses["CSE 5911"]; + assert.equal(entry.suppressed, 1); + assert.equal(entry.sections, 2); + assert.deepEqual(entry.counts, [10, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], "the published section is untouched"); +}); + +test("a partly withheld row is withheld, not summed from the half that survived", () => { + // Publishing 10 A's out of a row whose B column was redacted would print a + // curve whose total contradicts the section beside it. + const out = built("1218,CSE,5911,5168,0010,Columbus,fenwick.9@osu.edu,Wes Fenwick,10,2,0,*,0,0,0,0,0,0,0,0,0"); + const entry = out.snapshot.instructors["fenwick.9"].courses["CSE 5911"]; + assert.equal(entry.suppressed, 1); + assert.deepEqual(entry.counts, new Array(11).fill(0)); +}); + +test("another campus is dropped rather than folded into a Columbus curve", () => { + const out = built( + "1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,10,0,0,0,0,0,0,0,0,0,0,0,0", + "1218,CSE,2221,7001,0010,Newark,bucci.2@osu.edu,Paolo Bucci,99,0,0,0,0,0,0,0,0,0,0,0,0" + ); + assert.equal(out.snapshot.instructors["bucci.2"].courses["CSE 2221"].counts[0], 10); + assert.equal(out.skipped.get("another campus"), 1); +}); + +test("a campus column spelled as a code still matches", () => { + const out = built("1218,CSE,2221,5168,0010,COL,bucci.2@osu.edu,Paolo Bucci,10,0,0,0,0,0,0,0,0,0,0,0,0"); + assert.ok(out.snapshot.instructors["bucci.2"]); +}); + +test("Staff is not a person", () => { + const out = built( + "1218,CSE,2221,5168,0010,Columbus,,Staff,10,0,0,0,0,0,0,0,0,0,0,0,0", + "1218,CSE,2221,5169,0010,Columbus,,TBA,10,0,0,0,0,0,0,0,0,0,0,0,0", + "1218,CSE,2221,5170,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0" + ); + assert.deepEqual(Object.keys(out.snapshot.instructors), ["bucci.2"]); + assert.equal(out.skipped.get("unstaffed"), 2); +}); + +test("a row with no address is kept under its name, for the site to match on", () => { + const out = built("1218,MATH,1151,5168,0010,Columbus,,Diana Ikenberry Kline,10,0,0,0,0,0,0,0,0,0,0,0,0"); + const key = slug("Diana Ikenberry Kline"); + assert.equal(out.snapshot.instructors[key].name, "Diana Ikenberry Kline"); +}); + +test("the fullest spelling of a name wins across five years of exports", () => { + const out = built( + "1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,P Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0", + "1222,CSE,2221,5169,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0" + ); + assert.equal(out.snapshot.instructors["bucci.2"].name, "Paolo Bucci"); +}); + +test("the range comes from the terms actually present", () => { + const out = built( + "1262,CSE,2221,5170,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0", + "1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0", + "1238,CSE,2221,5169,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0" + ); + assert.equal(out.snapshot.firstTerm, "1218"); + assert.equal(out.snapshot.lastTerm, "1262"); + assert.equal(out.snapshot.termCount, 3); +}); + +test("an E column labelled F is still the failing mark", () => { + const header = HEADER.replace(",E,", ",F,"); + const out = buildSnapshot([header, "1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,0,0,0,0,0,0,0,0,0,0,7,0,0"].join("\n"), { minInstructors: 1 }); + assert.equal(out.snapshot.instructors["bucci.2"].courses["CSE 2221"].counts[10], 7); +}); + +test("too few instructors refuses the write", () => { + const out = built("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0"); + assert.equal(out.refusals.filter(Boolean).length, 0, "the floor was lowered for this test"); + + const strict = buildSnapshot(file("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0")); + const refusal = strict.refusals.find(Boolean); + assert.match(refusal.reason, /grades instructors: got 1, the floor is 200/); + assert.equal(refusal.forceable, false); +}); + +test("a run far short of the committed one is a forceable refusal", () => { + const rows = []; + for (let i = 0; i < 5; i++) { + rows.push(`1218,CSE,2221,516${i},0010,Columbus,person.${i}@osu.edu,Person ${i},1,0,0,0,0,0,0,0,0,0,0,0,0`); + } + const out = buildSnapshot(file(...rows), { minInstructors: 1, previous: 100 }); + const refusal = out.refusals.find(Boolean); + assert.match(refusal.reason, /down 95\.0% from the 100 already committed/); + assert.equal(refusal.forceable, true, "a real shrink has to be shippable"); +}); + +test("a file of unreadable rows refuses rather than writing what survived", () => { + const rows = ["1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0"]; + for (let i = 0; i < 20; i++) rows.push("1218,,,,,Columbus,,,,,,,,,,,,,,,"); + const out = buildSnapshot(file(...rows), { minInstructors: 1 }); + const refusal = out.refusals.find(Boolean); + assert.match(refusal.reason, /rows did not parse/); +}); + +test("a file with nothing under the header refuses", () => { + assert.match(buildSnapshot(HEADER).refusals[0].reason, /no rows under its header/); +}); + +test("readRow skips a row missing its course or term", () => { + const map = resolveHeaders(parseCsv(HEADER)[0]); + const cells = (line) => parseCsv(line)[0]; + assert.ok(readRow(cells("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,P,1,0,0,0,0,0,0,0,0,0,0,0,0"), map).term); + assert.equal(readRow(cells(",CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,P,1,0,0,0,0,0,0,0,0,0,0,0,0"), map).skip, "no course or term"); + assert.equal(readRow(cells("1218,,2221,5168,0010,Columbus,bucci.2@osu.edu,P,1,0,0,0,0,0,0,0,0,0,0,0,0"), map).skip, "no course or term"); +}); + +test("the snapshot names its own scale, so a reader never has to assume the order", () => { + const out = built("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,1,0,0,0,0,0,0,0,0,0,0,0,0"); + assert.deepEqual(out.snapshot.scale, ["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"]); +}); + +test("the sign on a grade column survives, so A- is not read out of the A column", () => { + const map = resolveHeaders(parseCsv(HEADER)[0]); + const at = map.grades; + // Eleven marks, eleven distinct columns, in the order the header lists them. + assert.equal(new Set(Object.values(at)).size, 11); + assert.equal(at["A"] + 1, at["A-"]); + assert.equal(at["B"] + 1, at["B-"]); + assert.ok(at["B+"] < at["B"]); + + // And end to end: a row that is nothing but A-minuses must not land on A. + const out = built("1218,CSE,2221,5168,0010,Columbus,bucci.2@osu.edu,Paolo Bucci,0,9,0,0,0,0,0,0,0,0,0,0,0"); + assert.deepEqual(out.snapshot.instructors["bucci.2"].courses["CSE 2221"].counts, [0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +}); + +test("a spelled-out sign resolves to the same column as the symbol", () => { + const spelled = "Term,Subject,Catalog,Instructor Email,A,A Minus,B Plus,B,B Minus,C Plus,C,C Minus,D Plus,D,E"; + const map = resolveHeaders(parseCsv(spelled)[0]); + assert.deepEqual(map.missing, []); + assert.equal(new Set(Object.values(map.grades)).size, 11); + assert.equal(map.grades["A-"], 5); + assert.equal(map.grades["B+"], 6); +}); diff --git a/tests/fixtures.js b/tests/fixtures.js index c081222..2a391cb 100644 --- a/tests/fixtures.js +++ b/tests/fixtures.js @@ -348,3 +348,91 @@ export function barrettFile(subject, term, rows = [], { for (const row of rows) lines.push(typeof row === "string" ? row : barrettLine({ subject, ...row })); return lines.join("\n"); } + +// Grade distributions, in the shape scripts/fetch-grades.mjs writes. Counts are +// positional against js/grades.js's SCALE: A A- B+ B B- C+ C C- D+ D E. +// +// Keys are the local part of an OSU address, which is what both the class API +// and the records request carry. Every instructor here exercises one join or +// one arithmetic case. +const GRADE_ROWS = { + // The ordinary case: joined by address, several terms, a few sections the + // registrar withheld. + "bucci.2": { + name: "Paolo Bucci", + terms: 9, + courses: { + "CSE 2221": { + counts: [180, 120, 95, 140, 60, 45, 70, 25, 15, 20, 90], + other: { W: 61, I: 4 }, + sections: 22, + terms: 9, + suppressed: 2, + }, + // Same professor, a course whose curve is nothing like the first, so a + // per-course lookup can be told from a per-instructor one. + "CSE 2231": { + counts: [40, 30, 20, 25, 10, 5, 5, 2, 1, 1, 6], + other: { W: 9 }, + sections: 6, + terms: 4, + suppressed: 0, + }, + }, + }, + // No address on the section, so this one can only be reached by name, and + // "Diana Ikenberry Kline" has to find it. + "kline.1": { + name: "Diana Kline", + terms: 5, + courses: { + "MATH 1151": { counts: [10, 8, 12, 20, 14, 9, 11, 6, 3, 4, 7], other: { W: 12 }, sections: 8, terms: 5, suppressed: 0 }, + // A catalog number with a suffix. 1110.02 must not answer for 1110.01. + "ENGLISH 1110.01": { counts: [5, 4, 3, 6, 2, 1, 1, 0, 0, 0, 1], other: {}, sections: 2, terms: 2, suppressed: 0 }, + }, + }, + // Two people, one name, no addresses. A name lookup must refuse both. + "reed.7": { name: "Alan Reed", terms: 3, courses: { "PHYSICS 1250": { counts: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], other: {}, sections: 3, terms: 3, suppressed: 0 } } }, + "reed.31": { name: "Alan Reed", terms: 2, courses: { "PHYSICS 1250": { counts: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 0], other: {}, sections: 2, terms: 2, suppressed: 0 } } }, + // A pass-fail course. Nothing here carries grade points, so there is no mean + // to print even though 140 students finished it. + "nkemelu.4": { + name: "Ada Nkemelu", + terms: 4, + courses: { "CSE 4998": { counts: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], other: { S: 120, U: 20, W: 3 }, sections: 4, terms: 4, suppressed: 0 } }, + }, + // Every section small enough to be withheld, so the record exists and the + // curve does not. The difference has to survive to the screen. + "fenwick.9": { + name: "Wes Fenwick", + terms: 2, + courses: { "CSE 5911": { counts: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], other: {}, sections: 3, terms: 2, suppressed: 3 } }, + }, + // Thin but real: one section, eight students, a perfect curve that means + // nothing. The pane has to say so rather than print a 4.0. + "whitfield.2": { + name: "Nora Whitfield", + terms: 1, + courses: { "CSE 3241": { counts: [6, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], other: {}, sections: 1, terms: 1, suppressed: 0 } }, + }, + // Malformed rows a bad parse could write. Both have to read as unknown. + "gomori.1": { + name: "Stephen Gomori", + terms: 1, + courses: { + "CSE 1223": { counts: [1, 2, 3], other: {}, sections: 1, terms: 1, suppressed: 0 }, + "CSE 1224": { counts: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, -1], other: {}, sections: 1, terms: 1, suppressed: 0 }, + }, + }, +}; + +export const GRADES = { + source: "The Ohio State University, public records request under R.C. 149.43", + campus: "col", + firstTerm: "Autumn 2021", + lastTerm: "Spring 2026", + termCount: 15, + scale: ["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "E"], + count: Object.keys(GRADE_ROWS).length, + instructors: GRADE_ROWS, +}; diff --git a/tests/grades.test.js b/tests/grades.test.js new file mode 100644 index 0000000..99cab8d --- /dev/null +++ b/tests/grades.test.js @@ -0,0 +1,222 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { withGrades } from "./helpers.js"; + +const grades = await withGrades(); + +const who = (name, email = null) => ({ name, email }); +const course = (subject, catalogNumber) => ({ subject, catalogNumber }); + +test("gradeKey takes the local part of an OSU address", () => { + assert.equal(grades.gradeKey("bucci.2@osu.edu"), "bucci.2"); + assert.equal(grades.gradeKey("BUCCI.2@osu.edu"), "bucci.2"); +}); + +test("gradeKey refuses anything that is not an address", () => { + assert.equal(grades.gradeKey(null), ""); + assert.equal(grades.gradeKey(""), ""); + assert.equal(grades.gradeKey("bucci.2"), ""); + assert.equal(grades.gradeKey("@osu.edu"), ""); +}); + +test("an address joins straight through to the curve", () => { + const curve = grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")); + assert.equal(curve.n, 860); + assert.equal(curve.sections, 22); + assert.equal(curve.withdrew, 61); +}); + +test("the mean is grade points over graded students, not over everyone", () => { + const curve = grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")); + // 61 withdrawals and 4 incompletes carry no points and are outside the mean. + const expected = (180 * 4 + 120 * 3.7 + 95 * 3.3 + 140 * 3 + 60 * 2.7 + 45 * 2.3 + + 70 * 2 + 25 * 1.7 + 15 * 1.3 + 20 * 1 + 90 * 0) / 860; + assert.ok(Math.abs(curve.gpa - expected) < 1e-9); +}); + +test("one instructor's two courses are two different curves", () => { + const person = who("Paolo Bucci", "bucci.2@osu.edu"); + const first = grades.gradesFor(person, course("CSE", "2221")); + const second = grades.gradesFor(person, course("CSE", "2231")); + assert.notEqual(first.n, second.n); + assert.ok(second.gpa > first.gpa); +}); + +test("a name finds the curve when the section carries no address", () => { + const curve = grades.gradesFor(who("Diana Ikenberry Kline"), course("MATH", "1151")); + assert.equal(curve.n, 104); +}); + +test("two people under one name return nothing rather than one of them", () => { + assert.equal(grades.gradesFor(who("Alan Reed"), course("PHYSICS", "1250")), null); +}); + +test("a catalog number is matched exactly, so 1110.02 never answers for 1110.01", () => { + const person = who("Diana Ikenberry Kline"); + assert.ok(grades.gradesFor(person, course("ENGLISH", "1110.01"))); + assert.equal(grades.gradesFor(person, course("ENGLISH", "1110.02")), null); + assert.equal(grades.gradesFor(person, course("ENGLISH", "1110")), null); +}); + +test("an instructor with a record but not in this course reads as unknown", () => { + assert.equal(grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("MATH", "1151")), null); +}); + +test("an unknown instructor reads as unknown", () => { + assert.equal(grades.gradesFor(who("Nobody Here", "nobody.1@osu.edu"), course("CSE", "2221")), null); + assert.equal(grades.gradesFor(who(""), course("CSE", "2221")), null); +}); + +test("a course with no subject or number is not a lookup", () => { + const person = who("Paolo Bucci", "bucci.2@osu.edu"); + assert.equal(grades.gradesFor(person, course("", "2221")), null); + assert.equal(grades.gradesFor(person, {}), null); +}); + +test("a pass-fail course has no mean, however many students finished it", () => { + const curve = grades.gradesFor(who("Ada Nkemelu", "nkemelu.4@osu.edu"), course("CSE", "4998")); + // 140 satisfactory and unsatisfactory marks carry no points between them. + assert.equal(curve, null); +}); + +test("a course whose every section was withheld is not a course with no students", () => { + const person = who("Wes Fenwick", "fenwick.9@osu.edu"); + assert.equal(grades.gradesFor(person, course("CSE", "5911")), null); + // The row still exists, which is what separates withheld from never taught. + assert.equal(grades.gradesOverall(person), null); +}); + +test("suppressed sections ride along with the curve they are missing from", () => { + const curve = grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")); + assert.equal(curve.suppressed, 2); +}); + +test("a malformed counts row reads as unknown, never as zero", () => { + const person = who("Stephen Gomori", "gomori.1@osu.edu"); + assert.equal(grades.gradesFor(person, course("CSE", "1223")), null, "too few columns"); + assert.equal(grades.gradesFor(person, course("CSE", "1224")), null, "a negative count"); +}); + +test("gpaOf refuses a row that is not the eleven marks", () => { + assert.equal(grades.gpaOf(null), null); + assert.equal(grades.gpaOf([1, 2, 3]), null); + assert.equal(grades.gpaOf(new Array(11).fill(0)), null, "nobody graded is no mean"); + assert.equal(grades.gpaOf(["4", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), null); +}); + +test("gpaOf puts an all-A course at 4.0 and an all-E course at 0", () => { + const only = (i) => { const row = new Array(11).fill(0); row[i] = 10; return row; }; + assert.equal(grades.gpaOf(only(0)).gpa, 4); + assert.equal(grades.gpaOf(only(10)).gpa, 0); + assert.equal(grades.gpaOf(only(10)).n, 10); +}); + +test("aShare counts A and A- and nothing else", () => { + const curve = grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")); + assert.ok(Math.abs(grades.aShare(curve) - 300 / 860) < 1e-9); + assert.equal(grades.aShare(null), null); +}); + +test("aShare separates two curves the mean cannot tell apart", () => { + // Half A and half E against a course that is entirely B-: both average 2.0, + // and only one of them is a coin flip. + const split = [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10]; + const middle = [0, 0, 0, 0, 0, 0, 20, 0, 0, 0, 0]; + assert.equal(grades.gpaOf(split).gpa, grades.gpaOf(middle).gpa); + assert.equal(grades.aShare({ counts: split, n: 20 }), 0.5); + assert.equal(grades.aShare({ counts: middle, n: 20 }), 0); +}); + +test("gradesOverall adds an instructor's courses together", () => { + const curve = grades.gradesOverall(who("Paolo Bucci", "bucci.2@osu.edu")); + assert.equal(curve.n, 860 + 145); + assert.equal(curve.sections, 28); + assert.equal(curve.suppressed, 2); +}); + +test("gradeSpread draws only when somebody was graded", () => { + const curve = grades.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")); + assert.equal(grades.gradeSpread(curve).total, 860); + assert.equal(grades.gradeSpread(null), null); + assert.equal(grades.gradeSpread({ n: 0, counts: new Array(11).fill(0) }), null); +}); + +test("the range says what the request covered", () => { + assert.deepEqual(grades.gradesRange(), { first: "Autumn 2021", last: "Spring 2026", terms: 15 }); +}); + +test("a thin curve is still returned, and says how thin", () => { + const curve = grades.gradesFor(who("Nora Whitfield", "whitfield.2@osu.edu"), course("CSE", "3241")); + assert.equal(curve.n, 8); + assert.equal(curve.sections, 1); +}); + +test("the snapshot loaded, so nothing reads as failed or absent", () => { + assert.equal(grades.gradesFailed(), false); + assert.equal(grades.gradesAbsent(), false); +}); + +test("gradesStatus separates the three ways a curve can be absent", () => { + assert.equal(grades.gradesStatus(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")), "ready"); + // Every section too small to publish. + assert.equal(grades.gradesStatus(who("Wes Fenwick", "fenwick.9@osu.edu"), course("CSE", "5911")), "withheld"); + // 140 students, all of them on satisfactory or unsatisfactory. + assert.equal(grades.gradesStatus(who("Ada Nkemelu", "nkemelu.4@osu.edu"), course("CSE", "4998")), "ungraded"); + // No record of this person teaching this course. + assert.equal(grades.gradesStatus(who("Paolo Bucci", "bucci.2@osu.edu"), course("MATH", "1151")), "unknown"); + assert.equal(grades.gradesStatus(who("Nobody Here", "nobody.1@osu.edu"), course("CSE", "2221")), "unknown"); +}); + +test("a malformed row is unknown rather than withheld or ungraded", () => { + const person = who("Stephen Gomori", "gomori.1@osu.edu"); + assert.equal(grades.gradesStatus(person, course("CSE", "1223")), "unknown"); + assert.equal(grades.gradesStatus(person, course("CSE", "1224")), "unknown"); +}); + +test("withheldFor counts the withheld sections either way", () => { + assert.equal(grades.withheldFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")), 2, "a curve survived"); + assert.equal(grades.withheldFor(who("Wes Fenwick", "fenwick.9@osu.edu"), course("CSE", "5911")), 3, "none did"); + assert.equal(grades.withheldFor(who("Nobody Here"), course("CSE", "2221")), 0); +}); + +test("an unpublished file is an answer, not a failure, and is never asked for twice", async () => { + let calls = 0; + const { stubFetch } = await import("./helpers.js"); + const missing = await import("../js/grades.js?unpublished"); + const restore = stubFetch(() => { calls++; return { ok: false, status: 404, json: async () => ({}) }; }); + try { + assert.equal(await missing.loadGrades("grades.json"), null); + assert.equal(await missing.loadGrades("grades.json"), null); + } finally { + restore(); + } + assert.equal(calls, 1, "a 404 settles it"); + assert.equal(missing.gradesAbsent(), true); + assert.equal(missing.gradesFailed(), false); + assert.equal(missing.gradesStatus(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")), "unpublished"); +}); + +test("a server error stays retryable, unlike a 404", async () => { + const { stubFetch } = await import("./helpers.js"); + const broken = await import("../js/grades.js?broken"); + let restore = stubFetch(() => ({ ok: false, status: 500, json: async () => ({}) })); + try { + await assert.rejects(() => broken.loadGrades("grades.json"), /grades 500/); + } finally { + restore(); + } + assert.equal(broken.gradesFailed(), true); + assert.equal(broken.gradesAbsent(), false); + + // The retry lands, and the failure flag has to clear the way ratings.js does. + const { GRADES } = await import("./fixtures.js"); + restore = stubFetch({ "grades.json": GRADES }); + try { + await broken.loadGrades("grades.json"); + } finally { + restore(); + } + assert.equal(broken.gradesFailed(), false); + assert.equal(broken.gradesFor(who("Paolo Bucci", "bucci.2@osu.edu"), course("CSE", "2221")).n, 860); +}); diff --git a/tests/helpers.js b/tests/helpers.js index d1a3a4c..73e4390 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -7,7 +7,7 @@ // it, these helpers serve a fixture over a stubbed fetch and let the real // loaders run. -import { HEADSHOTS, RATINGS, RATING_COURSES, SEATS_INDEX, SEATS_TERMS, TREND } from "./fixtures.js"; +import { GRADES, HEADSHOTS, RATINGS, RATING_COURSES, SEATS_INDEX, SEATS_TERMS, TREND } from "./fixtures.js"; /** * Swap in a fetch that serves fixtures by URL. Returns a restore function. @@ -184,3 +184,23 @@ export function cssRules(source, where = "the stylesheet") { .filter(([prop, value]) => prop && value))); }; } + +/** + * Load the grade snapshot into an instance. + * + * `data` set to null serves the 404 the site really gets until the records + * request is fulfilled, which is the state the feature ships in. + */ +export async function withGrades(data = GRADES, suffix = "") { + const mod = await import(`../js/grades.js${suffix}`); + const body = data === null + ? { ok: false, status: 404, json: async () => ({}) } + : data; + const restore = stubFetch({ "grades.json": body }); + try { + await mod.loadGrades("grades.json"); + } finally { + restore(); + } + return mod; +}