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; +}