diff --git a/css/finder.css b/css/finder.css index 5a84bbd..bd5b6bb 100644 --- a/css/finder.css +++ b/css/finder.css @@ -1064,13 +1064,37 @@ body { .gen-add { display: grid; - grid-template-columns: minmax(10rem, 20rem) auto; + grid-template-columns: minmax(10rem, 20rem) max-content; align-items: end; gap: 0.45rem; margin-bottom: 0.65rem; } .gen-add .f-label { grid-column: 1 / -1; margin: 0; } +.gen-input-wrap { position: relative; } +.gen-input-wrap > .f-ctl { width: 100%; } + +.gen-suggestions { + position: absolute; + z-index: 8; + top: calc(100% + 0.2rem); + right: 0; + left: 0; + margin: 0; + padding: 0.25rem; + border: 1px solid var(--field); + border-radius: var(--radius); + background: var(--paper); + box-shadow: 0 0.45rem 1rem rgb(0 0 0 / 12%); + list-style: none; +} + +.gen-suggestions[hidden] { display: none; } +.gen-suggestion { display: grid; width: 100%; padding: 0.45rem; border: 0; border-radius: 0.2rem; background: transparent; color: var(--ink); text-align: left; cursor: pointer; } +.gen-suggestion:hover, +.gen-suggestion:focus-visible { background: var(--wash); } +.gen-suggestion strong { font-family: var(--data); font-size: 0.8rem; } +.gen-suggestion span { color: var(--mute); font-size: 0.75rem; } .gen-add-button, .gen-generate, @@ -1138,6 +1162,24 @@ body { font-size: 0.8rem; } +.gen-help { color: var(--mute); cursor: help; font-size: 0.76rem; } +.gen-help:focus-visible { outline: 2px solid var(--scarlet); outline-offset: 2px; } + +.gen-modes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.2rem 1rem; + margin: 0.9rem 0 0; + padding: 0.7rem 0 0; + border: 0; + border-top: 1px solid var(--rule); +} + +.gen-modes .f-label { width: 100%; margin: 0; } +.gen-modes .gen-check { min-height: 1.8rem; } +.gen-mode-note { width: 100%; color: var(--mute); font-size: 0.72rem; } + .gen-weights { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); @@ -1147,7 +1189,7 @@ body { .gen-weight { display: grid; - grid-template-columns: minmax(8rem, 1fr) minmax(6rem, 1fr); + grid-template-columns: minmax(9rem, 1fr) minmax(6rem, 1fr) 5.5rem; align-items: center; gap: 0.6rem; color: var(--mute); @@ -1155,6 +1197,7 @@ body { } .gen-weight input { width: 100%; accent-color: var(--scarlet); } +.gen-weight-value { color: var(--ink); font-size: 0.7rem; text-align: right; } .gen-note { margin: 0 0 1rem; color: var(--mute); font-size: 0.85rem; } .gen-note.is-error { color: var(--scarlet); } .gen-results { margin-top: 1.4rem; } @@ -1168,12 +1211,13 @@ body { } .gen-result:first-of-type { border-color: var(--open); } +.gen-factors { margin: 0.25rem 0 0; color: var(--mute); font-size: 0.72rem; } .gen-use { background: transparent; color: var(--scarlet); } .gen-use:hover { background: var(--scarlet); color: var(--paper); } .gen-metrics { display: grid; - grid-template-columns: repeat(6, minmax(5.5rem, 1fr)); + grid-template-columns: repeat(4, minmax(5.5rem, 1fr)); gap: 0.6rem; margin: 0.9rem 0; } @@ -1185,6 +1229,10 @@ body { .gen-sections { margin: 0; padding: 0; border-top: 1px solid var(--rule); list-style: none; } .gen-sections li { display: grid; grid-template-columns: 8rem 1fr 1fr; gap: 0.7rem; padding: 0.55rem 0; border-bottom: 1px solid var(--rule); } .gen-sections span { color: var(--mute); font-size: 0.78rem; } +.gen-section-name { display: flex; align-items: center; gap: 0.4rem; } +.gen-pin { padding: 0.12rem 0.35rem; border: 1px solid var(--field); border-radius: 999px; background: transparent; color: var(--mute); font: inherit; font-size: 0.68rem; cursor: pointer; } +.gen-pin:hover, +.gen-pin.is-pinned { border-color: var(--scarlet); color: var(--scarlet); } @media (max-width: 46rem) { .gen-head { display: block; } diff --git a/js/app.js b/js/app.js index 5a7a3ef..0f55f70 100644 --- a/js/app.js +++ b/js/app.js @@ -2,7 +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, ratingFor, ratingsFailed } from "./ratings.js"; -import { gradesFor, loadGrades } from "./grades.js"; +import { loadGrades } from "./grades.js"; import { linkedTo, loadSeats, seatsFor, seatsTerm, seatsUpdated, seatsSectionCount, seatsFailed, withSeatInstructors } from "./seats.js"; import { loadTrend } from "./trend.js"; import { renderDetail } from "./detail.js"; @@ -11,14 +11,16 @@ import { applyFilters, hiddenFor, isActive, parseBusy, formatBusy, DEFAULTS } fr import { busyLabel } from "./format.js"; import { renderCalendar } from "./calendar.js"; import { formatCoverage } from "./format.js"; -import { loadCourses, subjectsFor, subjectLabel, coursesFor, codeFromInput, isLoaded } from "./courses.js"; +import { loadCourses, subjectsFor, subjectLabel, coursesFor, courseSuggestions, codeFromInput, isLoaded } from "./courses.js"; import { classFromParams, setClassParam, sameSearch, hasSection, missOutcome } from "./deeplink.js"; import { isSortKey, sortEntries, unknownSections } from "./sort.js"; import { addScheduleItem, formatPlan, isScheduled, loadSchedule, parsePlan, removeScheduleItem, renderSchedule, saveSchedule, scheduleEntries, scheduleIds, scheduleKey, } from "./schedule.js"; -import { DEFAULT_GENERATOR_WEIGHTS, describePackage, expandCoursePackages, generateSchedules } from "./generator.js"; +import { + DEFAULT_GENERATOR_WEIGHTS, INSTRUCTION_MODES, describePackage, expandCoursePackages, generateSchedules, packageKey, +} from "./generator.js"; import { renderGenerator } from "./generator-render.js"; const els = { @@ -92,7 +94,11 @@ let schedule = loadSchedule(); let scheduleNote = ""; let generator = { desired: [], - preferences: { earliest: "", latest: "", excludeFull: true, weights: { ...DEFAULT_GENERATOR_WEIGHTS } }, + preferences: { + earliest: "", latest: "", excludeFull: true, + allowedModes: [...INSTRUCTION_MODES], weights: { ...DEFAULT_GENERATOR_WEIGHTS }, + }, + pins: {}, results: [], phase: "idle", note: "", @@ -589,15 +595,39 @@ function normalizeCourseCode(raw) { return match ? `${match[1].toUpperCase()} ${match[2].toUpperCase()}` : ""; } -function addDesiredCourse(raw) { - const code = normalizeCourseCode(raw); - if (!code) { - generator.note = "Enter a subject and course number, like CSE 2221."; +async function generatorSuggestions(raw) { + if (!String(raw ?? "").trim()) return []; + await loadCourses(); + return courseSuggestions(els.term.value, raw); +} + +async function addDesiredCourse(raw) { + let choice = typeof raw === "object" && raw ? raw : null; + if (!choice) { + const code = normalizeCourseCode(raw); + if (!code) { + generator.note = "Type part of a course number or title, then choose a course."; + generator.phase = "error"; + paintGenerator(); + return false; + } + try { + const suggestions = await generatorSuggestions(code); + choice = suggestions.find((candidate) => candidate.key === code || candidate.code === code) ?? null; + } catch { + generator.note = "Could not load the course list. Try again."; + generator.phase = "error"; + paintGenerator(); + return false; + } + } + if (!choice) { + generator.note = "Choose an offered course from the suggestions."; generator.phase = "error"; paintGenerator(); return false; } - if (!generator.desired.includes(code)) generator.desired.push(code); + if (!generator.desired.some((candidate) => candidate.key === choice.key)) generator.desired.push(choice); generator.results = []; generator.phase = "idle"; generator.note = ""; @@ -605,8 +635,9 @@ function addDesiredCourse(raw) { return true; } -function removeDesiredCourse(code) { - generator.desired = generator.desired.filter((candidate) => candidate !== code); +function removeDesiredCourse(key) { + generator.desired = generator.desired.filter((candidate) => candidate.key !== key); + delete generator.pins[key]; generator.results = []; generator.phase = "idle"; generator.note = ""; @@ -621,7 +652,7 @@ function exactCourse(courses, code) { ) ?? null; } -async function coursePackages(code, term) { +async function packagesForExactCourse(code, term) { const result = await searchAllPages({ q: code, term }); let entry = exactCourse(result.courses, code); if (!entry) return { code, packages: [], missingCourse: true, missingLinked: [] }; @@ -648,12 +679,56 @@ async function coursePackages(code, term) { return { code, - packages: expanded.packages.map((pkg) => describePackage(pkg, term, { seatsFor, ratingFor, gradesFor })), + packages: expanded.packages.map((pkg) => describePackage(pkg, term, { seatsFor, ratingFor })), missingCourse: false, missingLinked: expanded.missing, }; } +async function coursePackages(choice, term) { + const variants = await Promise.all(choice.codes.map((code) => packagesForExactCourse(code, term))); + const packages = variants.flatMap((variant) => variant.packages).map((pkg) => ({ + ...pkg, + desiredKey: choice.key, + desiredLabel: choice.family ? `${choice.code} (any version)` : choice.code, + packageKey: packageKey(pkg.sections), + })); + return { + key: choice.key, + label: choice.family ? `${choice.code} (any version)` : choice.code, + course: packages[0]?.course, + packages, + missingCourse: variants.every((variant) => variant.missingCourse), + missingLinked: choice.family && packages.length + ? [] + : [...new Set(variants.flatMap((variant) => variant.missingLinked))], + }; +} + +function generatorFailureNote(diagnostics) { + const blocked = diagnostics?.blockedGroups?.[0]; + if (blocked) { + const reasons = Object.entries(blocked.removed).sort((a, b) => b[1] - a[1]); + const reason = reasons[0]?.[1] ? reasons[0][0] : "unknown"; + const messages = { + full: "all of its registration choices are full", + internal: "its linked sections overlap each other", + time: "all choices fall outside your time limits", + busy: "all choices overlap a busy period", + mode: "all choices use an excluded instruction mode", + pin: "the pinned section is no longer available", + unknown: "it has no complete registration choices", + }; + return `${blocked.label} cannot be scheduled because ${messages[reason]}.`; + } + const conflict = diagnostics?.conflicts?.[0]; + if (conflict) { + const days = conflict.days.map((day) => day[0].toUpperCase() + day.slice(1)).join(" and "); + return `${conflict.a} conflicts with ${conflict.b}${days ? ` on ${days}` : ""}. Try another course, change a pin, or widen your constraints.`; + } + return "No conflict-free schedule fits these courses and constraints. Try widening the time window, allowing full classes, or changing a busy period."; +} + async function runGenerator(preferences) { generator.preferences = preferences; generator.phase = "loading"; @@ -667,17 +742,16 @@ async function runGenerator(preferences) { await Promise.all([ loadSeats(term), loadRatings().catch(() => null), - loadGrades().catch(() => null), ]); - const groups = await Promise.all(generator.desired.map((code) => coursePackages(code, term))); + const groups = await Promise.all(generator.desired.map((choice) => coursePackages(choice, term))); if (request !== generatorRequest) return; - const missing = groups.filter((group) => group.missingCourse).map((group) => group.code); + const missing = groups.filter((group) => group.missingCourse).map((group) => group.label); const incomplete = groups.filter((group) => group.missingLinked.length); if (missing.length || incomplete.length) { const notes = []; if (missing.length) notes.push(`Not offered: ${missing.join(", ")}.`); - if (incomplete.length) notes.push(`Could not resolve every linked section for ${incomplete.map((group) => group.code).join(", ")}.`); + if (incomplete.length) notes.push(`Could not resolve every linked section for ${incomplete.map((group) => group.label).join(", ")}.`); generator.phase = "error"; generator.note = notes.join(" "); paintGenerator(); @@ -690,13 +764,15 @@ async function runGenerator(preferences) { earliest: fieldMinutes(preferences.earliest), latest: fieldMinutes(preferences.latest), excludeFull: preferences.excludeFull, + allowedModes: preferences.allowedModes, + pins: generator.pins, }); generator.results = generated.results; generator.phase = "ready"; const suffix = generated.truncated ? " Finder stopped after checking 100,000 choices, so this is the best set checked rather than every possible schedule." : ""; generator.note = generated.results.length ? `${generated.results.length} best conflict-free schedules from ${generated.visited.toLocaleString()} compatible choices.${suffix}` - : "No conflict-free schedule fits these courses and constraints. Try widening the time window, allowing full sections, or changing a busy period."; + : generatorFailureNote(generated.diagnostics); paintGenerator(); } catch (error) { if (request !== generatorRequest) return; @@ -723,6 +799,13 @@ function useGeneratedSchedule(result, term = els.term.value) { els.results.focus(); } +function toggleGeneratorPin(pkg) { + const key = pkg.desiredKey ?? `${pkg.course.subject} ${pkg.course.catalogNumber}`; + if (generator.pins[key] === pkg.packageKey) delete generator.pins[key]; + else generator.pins[key] = pkg.packageKey; + runGenerator(generator.preferences); +} + function paintGenerator(term = els.term.value) { view = "generator"; els.welcome.hidden = true; @@ -731,8 +814,10 @@ function paintGenerator(term = els.term.value) { onBack: () => setView("schedule"), onAdd: addDesiredCourse, onRemove: removeDesiredCourse, + onSuggest: generatorSuggestions, onGenerate: runGenerator, onUse: (result) => useGeneratedSchedule(result, term), + onPin: toggleGeneratorPin, })); resetDetail(); els.app.dataset.view = "results"; diff --git a/js/courses.js b/js/courses.js index 8f94123..b7ac102 100644 --- a/js/courses.js +++ b/js/courses.js @@ -54,6 +54,75 @@ export function coursesFor(term, code) { return (subject.courses ?? []).map(([number, title, min, max]) => ({ number, title, min, max })); } +function choice(subject, course) { + const code = `${subject.code} ${course.number}`; + return { key: code, code, title: course.title, codes: [code], family: false }; +} + +/** + * Term-scoped course choices for the schedule builder. + * + * Matches partial catalog numbers and title words, since the point of this + * control is remembering neither the whole number nor the registrar's exact + * title. An unsuffixed number with decimal variants also gets one explicit + * "any version" choice; the variants remain separate choices underneath it. + */ +export function courseSuggestions(term, raw, limit = 8) { + const query = String(raw ?? "").trim().replace(/\s+/g, " "); + if (!query) return []; + const upper = query.toUpperCase(); + const allSubjects = subjectsFor(term); + const firstSpace = upper.indexOf(" "); + const first = firstSpace < 0 ? upper : upper.slice(0, firstSpace); + const exactSubject = allSubjects.find((subject) => subject.code === first) ?? null; + const rest = exactSubject ? upper.slice(first.length).trim() : upper; + const ranked = []; + + for (const subject of exactSubject ? [exactSubject] : allSubjects) { + for (const course of coursesFor(term, subject.code)) { + const number = String(course.number).toUpperCase(); + const title = String(course.title ?? "").toUpperCase(); + const code = `${subject.code} ${number}`; + let score = 0; + if (upper === code) score = 120; + else if (exactSubject && number.startsWith(rest)) score = 100 - Math.min(20, number.length - rest.length); + else if (!exactSubject && number.startsWith(upper)) score = 88 - Math.min(20, number.length - upper.length); + else if (title.startsWith(rest)) score = exactSubject ? 82 : 78; + else if (rest && title.includes(rest)) score = exactSubject ? 72 : 68; + else if (!exactSubject && code.includes(upper)) score = 60; + if (score) ranked.push({ score, value: choice(subject, course) }); + } + } + + // ENGLISH 1110 is a useful request even though only 1110.01 and 1110.02 are + // offered. Make that choice explicit rather than calling the base missing or + // silently pretending the two catalog entries are one course. + const familyMatch = /^([A-Z]{2,8})\s+(\d{3,4})$/.exec(upper); + if (familyMatch) { + const [, subjectCode, stem] = familyMatch; + const variants = coursesFor(term, subjectCode).filter((course) => String(course.number).startsWith(`${stem}.`)); + const exact = coursesFor(term, subjectCode).some((course) => String(course.number) === stem); + if (!exact && variants.length) { + ranked.push({ + score: 130, + value: { + key: `${subjectCode} ${stem}.*`, + code: `${subjectCode} ${stem}`, + title: `Any ${stem} version`, + codes: variants.map((course) => `${subjectCode} ${course.number}`), + family: true, + }, + }); + } + } + + const unique = new Map(); + for (const found of ranked.sort((a, b) => b.score - a.score || a.value.code.localeCompare(b.value.code))) { + if (!unique.has(found.value.key)) unique.set(found.value.key, found.value); + } + return [...unique.values()].slice(0, Math.max(1, limit)); +} + /** Pull a bare subject code out of whatever the picker input holds. */ export function codeFromInput(text) { return String(text ?? "").trim().split(/[\s—-]/)[0].toUpperCase(); diff --git a/js/generator-render.js b/js/generator-render.js index 96cf548..4aa3463 100644 --- a/js/generator-render.js +++ b/js/generator-render.js @@ -2,14 +2,22 @@ import { formatWhen, instructorsOf } from "./format.js"; const WEIGHTS = [ ["seats", "Open seats"], - ["rating", "Instructor ratings"], - ["grades", "Grade history"], + ["rating", "RateMyProfessors rating"], ["lateStart", "Later starts"], ["earlyEnd", "Earlier finishes"], - ["compactDays", "Fewer campus days"], ["compactGaps", "Shorter gaps"], ]; +const MODE_CHOICES = [ + ["inPerson", "In person"], + ["hybrid", "Hybrid"], + ["distance", "Online / distance"], + ["arranged", "Arranged / other"], +]; + +const PRIORITY_LABELS = ["Ignore", "Tie-breaker", "Important", "Very important"]; +const FACTOR_LABELS = new Map(WEIGHTS); + function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; @@ -30,12 +38,22 @@ function metric(value, label) { return item; } -function resultCard(result, index, onUse) { +function factorBreakdown(result, weights) { + const available = Object.entries(result.factors ?? {}) + .filter(([key, value]) => Number.isFinite(value) && Number(weights?.[key]) > 0) + .sort((a, b) => (Number(weights[b[0]]) * b[1]) - (Number(weights[a[0]]) * a[1])) + .slice(0, 3) + .map(([key, value]) => `${FACTOR_LABELS.get(key) ?? key}: ${value >= 67 ? "strong" : value >= 34 ? "mixed" : "weak"}`); + return available.length ? available.join(" · ") : "No ranking priorities selected"; +} + +function resultCard(result, index, preferences, pins, onUse, onPin) { const card = el("article", "gen-result"); const head = el("header", "gen-result-head"); const title = el("div"); - title.append(el("p", "eyebrow", `Option ${index + 1}`)); - title.append(el("h3", "gen-result-title", `${Math.round(result.score)} match`)); + title.append(el("p", "eyebrow", index === 0 ? "Top result" : `Option ${index + 1}`)); + title.append(el("h3", "gen-result-title", index === 0 ? "Best fit" : `Alternative ${index + 1}`)); + title.append(el("p", "gen-factors", factorBreakdown(result, preferences.weights))); const use = el("button", "gen-use", "Use this schedule"); use.type = "button"; use.addEventListener("click", () => onUse(result)); @@ -47,8 +65,6 @@ function resultCard(result, index, onUse) { metrics.append( metric(m.minSeats == null ? "—" : String(m.minSeats), "minimum seats"), metric(m.rating == null ? "—" : m.rating.toFixed(1), "average rating"), - metric(m.gpa == null ? "—" : m.gpa.toFixed(2), "historical GPA"), - metric(String(m.days), "campus days"), metric(`${m.gaps} min`, "weekly gaps"), metric(`${clock(m.averageStart)}–${clock(m.averageEnd)}`, "average day"), ); @@ -61,8 +77,18 @@ function resultCard(result, index, onUse) { const numbers = pkg.sections.map((section) => section.classNumber).join(", "); const people = [...new Set(pkg.sections.flatMap((section) => instructorsOf(section).map((person) => person.name)))]; const first = pkg.sections.flatMap((section) => section.meetings ?? [])[0]; - item.append(el("strong", null, code)); - item.append(el("span", null, `Sections ${numbers}${first ? ` · ${formatWhen(first)}` : ""}`)); + const identity = el("div", "gen-section-name"); + identity.append(el("strong", null, code)); + const pinKey = pkg.desiredKey ?? code; + const pinned = pins?.[pinKey] === pkg.packageKey; + const pin = el("button", `gen-pin${pinned ? " is-pinned" : ""}`, pinned ? "Pinned" : "Pin"); + pin.type = "button"; + pin.setAttribute("aria-pressed", String(pinned)); + pin.setAttribute("aria-label", `${pinned ? "Unpin" : "Pin"} ${code} sections ${numbers}`); + pin.addEventListener("click", () => onPin(pkg)); + identity.append(pin); + item.append(identity); + item.append(el("span", null, `Sections ${numbers}${first ? ` · ${formatWhen(first)}` : " · No set meeting time"}`)); if (people.length) item.append(el("span", null, people.join(" & "))); list.append(item); } @@ -71,8 +97,8 @@ function resultCard(result, index, onUse) { } export function renderGenerator({ - desired, preferences, results = [], phase = "idle", note = "", - onBack, onAdd, onRemove, onGenerate, onUse, + desired, preferences, results = [], phase = "idle", note = "", pins = {}, + onBack, onAdd, onRemove, onSuggest = async () => [], onGenerate, onUse, onPin = () => {}, }) { const wrap = el("section", "generator"); const head = el("header", "gen-head"); @@ -84,7 +110,7 @@ export function renderGenerator({ back.addEventListener("click", onBack); head.append(heading, back); wrap.append(head); - wrap.append(el("p", "gen-intro", "Add each course you want. Finder will keep linked lectures, labs and recitations together, apply the busy times in Filters, then rank complete schedules using the priorities below.")); + wrap.append(el("p", "gen-intro", "Add each course you want. Finder keeps linked lectures, labs and recitations together, applies busy times from Filters, then ranks complete schedules using your priorities.")); const addForm = el("form", "gen-add"); const label = el("label", "f-label", "Desired course"); @@ -92,32 +118,67 @@ export function renderGenerator({ const input = el("input", "f-ctl"); input.setAttribute("id", "gen-course"); input.type = "text"; - input.placeholder = "CSE 2221"; + input.placeholder = "CSE 24 or systems"; input.autocomplete = "off"; + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("aria-controls", "gen-suggestions"); + const suggestions = el("ul", "gen-suggestions"); + suggestions.setAttribute("id", "gen-suggestions"); + suggestions.setAttribute("role", "listbox"); + suggestions.hidden = true; + let suggestionRequest = 0; + const showSuggestions = async () => { + const request = ++suggestionRequest; + const choices = await onSuggest(input.value); + if (request !== suggestionRequest) return; + suggestions.replaceChildren(); + for (const choice of choices) { + const item = el("li"); + item.setAttribute("role", "option"); + const button = el("button", "gen-suggestion"); + button.type = "button"; + button.append(el("strong", null, choice.code), el("span", null, choice.title)); + button.addEventListener("click", async () => { + if (await onAdd(choice)) input.value = ""; + suggestions.hidden = true; + }); + item.append(button); + suggestions.append(item); + } + suggestions.hidden = choices.length === 0; + }; + input.addEventListener("input", showSuggestions); + input.addEventListener("focus", showSuggestions); + const inputWrap = el("div", "gen-input-wrap"); + inputWrap.append(input, suggestions); const add = el("button", "gen-add-button", "Add course"); add.type = "submit"; - addForm.append(label, input, add); - addForm.addEventListener("submit", (event) => { + addForm.append(label, inputWrap, add); + addForm.addEventListener("submit", async (event) => { event.preventDefault(); - if (onAdd(input.value)) input.value = ""; + if (await onAdd(input.value)) { + input.value = ""; + suggestions.hidden = true; + } }); wrap.append(addForm); const chips = el("ul", "gen-courses"); - for (const code of desired) { + for (const choice of desired) { + const code = choice.code ?? choice; + const key = choice.key ?? code; const item = el("li"); - const remove = el("button", "gen-course-chip", `${code} ×`); + const remove = el("button", "gen-course-chip", `${code}${choice.family ? " (any version)" : ""} ×`); remove.type = "button"; remove.setAttribute("aria-label", `Remove ${code}`); - remove.addEventListener("click", () => onRemove(code)); + remove.addEventListener("click", () => onRemove(key)); item.append(remove); chips.append(item); } wrap.append(chips); const form = el("form", "gen-preferences"); - const legend = el("h3", "gen-subtitle", "Constraints and priorities"); - form.append(legend); + form.append(el("h3", "gen-subtitle", "Constraints and priorities")); const bounds = el("div", "gen-bounds"); const earliestLabel = el("label", "f-label", "No class before"); @@ -134,15 +195,43 @@ export function renderGenerator({ const excludeFull = el("input"); excludeFull.type = "checkbox"; excludeFull.checked = preferences.excludeFull !== false; - fullLabel.append(excludeFull, document.createTextNode("Exclude full registration packages")); + fullLabel.append(excludeFull, document.createTextNode("Only open classes")); + const fullHelp = el("span", "gen-help", "ⓘ"); + fullHelp.title = "Hide any choice where a required lecture, lab, or recitation is full."; + fullHelp.tabIndex = 0; + fullHelp.setAttribute("aria-label", fullHelp.title); + fullLabel.append(fullHelp); bounds.append(earliestLabel, latestLabel, fullLabel); form.append(bounds); + const modes = el("fieldset", "gen-modes"); + modes.append(el("legend", "f-label", "Instruction modes")); + const modeControls = new Map(); + for (const [key, text] of MODE_CHOICES) { + const modeLabel = el("label", "gen-check"); + const checkbox = el("input"); + checkbox.type = "checkbox"; + checkbox.checked = preferences.allowedModes?.includes(key) ?? true; + modeControls.set(key, checkbox); + modeLabel.append(checkbox, document.createTextNode(text)); + modes.append(modeLabel); + } + modes.append(el("span", "gen-mode-note", "Online / distance includes classes with no set meeting time.")); + form.append(modes); + const weights = el("div", "gen-weights"); const controls = new Map(); for (const [key, text] of WEIGHTS) { const control = el("label", "gen-weight"); - control.append(el("span", null, text)); + const name = el("span", "gen-weight-name", text); + if (key === "rating") { + const help = el("span", "gen-help", "ⓘ"); + help.title = "Uses the instructor's overall RateMyProfessors rating when at least 5 ratings are available. Unrated instructors are neutral."; + help.tabIndex = 0; + help.setAttribute("aria-label", help.title); + name.append(document.createTextNode(" "), help); + } + control.append(name); const range = el("input"); range.type = "range"; range.min = "0"; @@ -150,8 +239,10 @@ export function renderGenerator({ range.step = "1"; range.value = String(preferences.weights?.[key] ?? 1); range.setAttribute("aria-label", `${text} priority, zero to three`); + const output = el("output", "gen-weight-value", PRIORITY_LABELS[Number(range.value)]); + range.addEventListener("input", () => { output.textContent = PRIORITY_LABELS[Number(range.value)]; }); controls.set(key, range); - control.append(range); + control.append(range, output); weights.append(control); } form.append(weights); @@ -166,7 +257,8 @@ export function renderGenerator({ earliest: earliest.value, latest: latest.value, excludeFull: excludeFull.checked, - weights: Object.fromEntries([...controls].map(([key, input]) => [key, Number(input.value)])), + allowedModes: [...modeControls].filter(([, checkbox]) => checkbox.checked).map(([key]) => key), + weights: Object.fromEntries([...controls].map(([key, range]) => [key, Number(range.value)])), }); }); wrap.append(form); @@ -179,8 +271,8 @@ export function renderGenerator({ if (results.length) { const list = el("div", "gen-results"); - list.append(el("h3", "gen-subtitle", `${results.length} best schedule${results.length === 1 ? "" : "s"}`)); - results.forEach((result, index) => list.append(resultCard(result, index, onUse))); + list.append(el("h3", "gen-subtitle", `${results.length} schedule${results.length === 1 ? "" : "s"}`)); + results.forEach((result, index) => list.append(resultCard(result, index, preferences, pins, onUse, onPin))); wrap.append(list); } diff --git a/js/generator.js b/js/generator.js index 171392b..1ba20a8 100644 --- a/js/generator.js +++ b/js/generator.js @@ -2,7 +2,7 @@ // fetches: app.js supplies complete course entries and the snapshots already // loaded for the selected term. -import { instructorsOf } from "./format.js"; +import { instructorsOf, isOnlineMeeting } from "./format.js"; import { toMinutes } from "./filters.js"; const DAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; @@ -10,13 +10,13 @@ const DAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "satur export const DEFAULT_GENERATOR_WEIGHTS = { seats: 1, rating: 1, - grades: 1, lateStart: 1, earlyEnd: 1, - compactDays: 1, compactGaps: 1, }; +export const INSTRUCTION_MODES = ["inPerson", "hybrid", "distance", "arranged"]; + function clamp(value, min = 0, max = 100) { return Math.min(max, Math.max(min, value)); } @@ -55,10 +55,20 @@ function uniqueSections(sections) { return [...new Map(sections.map((section) => [String(section.classNumber), section])).values()]; } -function packageKey(sections) { +export function packageKey(sections) { return sections.map((section) => String(section.classNumber)).sort().join(","); } +export function instructionModeOf(section) { + const mode = String(section?.instructionMode ?? "").toLowerCase(); + if (mode.includes("hybrid") || mode.includes("enhanced")) return "hybrid"; + if (mode.includes("distance") || mode.includes("online")) return "distance"; + const meetings = section?.meetings ?? []; + if (meetings.length && meetings.every(isOnlineMeeting)) return "distance"; + if (mode.includes("person") || meetings.length) return "inPerson"; + return "arranged"; +} + /** * Expand one course into complete registration packages. * @@ -122,7 +132,7 @@ function knownAverage(values) { } /** Add ranges and ranking evidence to a registration package. */ -export function describePackage(pkg, term, { seatsFor, ratingFor, gradesFor }) { +export function describePackage(pkg, term, { seatsFor, ratingFor }) { const sections = uniqueSections(pkg.sections ?? []); const ranges = sections.flatMap(sectionRanges); const seatCounts = sections.map((section) => { @@ -140,14 +150,11 @@ export function describePackage(pkg, term, { seatsFor, ratingFor, gradesFor }) { } const ratings = []; - const grades = []; for (const person of people.values()) { const rating = ratingFor(person.name); if (rating && rating.numRatings >= 5 && Number.isFinite(Number(rating.avgRating))) { ratings.push(Number(rating.avgRating)); } - const curve = gradesFor(person, pkg.course); - if (curve && Number.isFinite(Number(curve.gpa)) && curve.n >= 10) grades.push(Number(curve.gpa)); } return { @@ -157,7 +164,7 @@ export function describePackage(pkg, term, { seatsFor, ratingFor, gradesFor }) { minSeats: seatCounts.length === sections.length ? Math.min(...seatCounts) : null, hasFull, rating: knownAverage(ratings), - gpa: knownAverage(grades), + modes: [...new Set(sections.map(instructionModeOf))], }; } @@ -198,15 +205,12 @@ export function scoreCombination(packages, weights = DEFAULT_GENERATOR_WEIGHTS) const minSeats = packages.every((pkg) => Number.isFinite(pkg.minSeats)) ? Math.min(...packages.map((pkg) => pkg.minSeats)) : null; const rating = knownAverage(packages.map((pkg) => pkg.rating)); - const gpa = knownAverage(packages.map((pkg) => pkg.gpa)); const factors = { seats: minSeats == null ? null : clamp(minSeats * 10), rating: rating == null ? null : clamp((rating - 1) * 25), - grades: gpa == null ? null : clamp(gpa * 25), lateStart: shape.averageStart == null ? null : clamp((shape.averageStart - 420) / 6.6), earlyEnd: shape.averageEnd == null ? null : clamp((1320 - shape.averageEnd) / 7.2), - compactDays: shape.days ? clamp((6 - shape.days) * 20) : null, compactGaps: clamp(100 - shape.gaps / 4.8), }; @@ -221,7 +225,7 @@ export function scoreCombination(packages, weights = DEFAULT_GENERATOR_WEIGHTS) return { score: totalWeight ? points / totalWeight : 0, - metrics: { minSeats, rating, gpa, ...shape }, + metrics: { minSeats, rating, ...shape }, factors, }; } @@ -233,34 +237,58 @@ export function scoreCombination(packages, weights = DEFAULT_GENERATOR_WEIGHTS) */ export function generateSchedules(coursePackages, { busy = [], weights = DEFAULT_GENERATOR_WEIGHTS, maxResults = 25, maxVisited = 100000, - excludeFull = true, earliest = null, latest = null, + excludeFull = true, earliest = null, latest = null, allowedModes = INSTRUCTION_MODES, + pins = {}, } = {}) { const busyRanges = busy.flatMap((block) => (block.days ?? []).map((day) => ({ day, start: block.start, end: block.end }))); - const prepared = coursePackages.map((group) => ({ - ...group, - packages: (group.packages ?? []).filter((pkg) => { - if (excludeFull && (pkg.hasFull || pkg.minSeats === 0)) return false; - if (hasInternalConflict(pkg.ranges)) return false; - if (Number.isFinite(earliest) && pkg.ranges.some((range) => range.start < earliest)) return false; - if (Number.isFinite(latest) && pkg.ranges.some((range) => range.end > latest)) return false; - return !hasConflict(pkg.ranges, busyRanges); - }), - })).sort((a, b) => a.packages.length - b.packages.length); + const allowed = new Set(allowedModes); + const blockedGroups = []; + const prepared = coursePackages.map((group) => { + const removed = { full: 0, internal: 0, time: 0, busy: 0, mode: 0, pin: 0 }; + const wantedPin = pins[group.key]; + const packages = (group.packages ?? []).filter((pkg) => { + if (wantedPin && packageKey(pkg.sections) !== wantedPin) { removed.pin += 1; return false; } + if (excludeFull && (pkg.hasFull || pkg.minSeats === 0)) { removed.full += 1; return false; } + if (hasInternalConflict(pkg.ranges)) { removed.internal += 1; return false; } + if (Number.isFinite(earliest) && pkg.ranges.some((range) => range.start < earliest)) { removed.time += 1; return false; } + if (Number.isFinite(latest) && pkg.ranges.some((range) => range.end > latest)) { removed.time += 1; return false; } + if (pkg.modes?.some((mode) => !allowed.has(mode))) { removed.mode += 1; return false; } + if (hasConflict(pkg.ranges, busyRanges)) { removed.busy += 1; return false; } + return true; + }); + if (!packages.length) blockedGroups.push({ key: group.key, label: group.label ?? group.key, total: group.packages?.length ?? 0, removed }); + return { ...group, packages }; + }).sort((a, b) => a.packages.length - b.packages.length); if (prepared.some((group) => !group.packages.length)) { - return { results: [], visited: 0, truncated: false }; + return { results: [], visited: 0, truncated: false, diagnostics: { blockedGroups, conflicts: [] } }; } const results = []; + const conflicts = new Map(); let visited = 0; let truncated = false; + function recordConflict(pkg, chosen) { + for (const other of chosen) { + const pairs = pkg.ranges.flatMap((a) => other.ranges.filter((b) => overlaps(a, b)).map((b) => [a, b])); + if (!pairs.length) continue; + const labels = [pkg.desiredLabel ?? `${pkg.course.subject} ${pkg.course.catalogNumber}`, + other.desiredLabel ?? `${other.course.subject} ${other.course.catalogNumber}`].sort(); + const key = labels.join("|"); + const row = conflicts.get(key) ?? { a: labels[0], b: labels[1], count: 0, days: new Set() }; + row.count += 1; + for (const [range] of pairs) row.days.add(range.day); + conflicts.set(key, row); + } + } + function keep(packages) { const scored = scoreCombination(packages, weights); const key = packages.map((pkg) => packageKey(pkg.sections)).sort().join("|"); results.push({ packages: [...packages], ...scored, key }); results.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key)); - if (results.length > maxResults) results.length = maxResults; + if (results.length > maxResults * 8) results.length = maxResults * 8; } function walk(index, chosen, occupied) { @@ -270,12 +298,34 @@ export function generateSchedules(coursePackages, { for (const pkg of prepared[index].packages) { visited += 1; if (visited > maxVisited) { truncated = true; return; } - if (hasConflict(pkg.ranges, occupied)) continue; + if (hasConflict(pkg.ranges, occupied)) { recordConflict(pkg, chosen); continue; } walk(index + 1, [...chosen, pkg], [...occupied, ...pkg.ranges]); if (truncated) return; } } walk(0, [], []); - return { results, visited, truncated }; + const diverse = []; + const remaining = [...results]; + const signature = (result) => new Map(result.packages.map((pkg) => [pkg.desiredKey ?? `${pkg.course.subject} ${pkg.course.catalogNumber}`, packageKey(pkg.sections)])); + const differences = (left, right) => { + const a = signature(left); + const b = signature(right); + return [...a].reduce((count, [key, value]) => count + (b.get(key) === value ? 0 : 1), 0); + }; + while (remaining.length && diverse.length < maxResults) { + const index = diverse.length === 0 ? 0 : remaining.findIndex((candidate) => + diverse.every((picked) => differences(candidate, picked) >= (candidate.packages.length >= 3 ? 2 : 1)) + ); + if (index < 0) break; + diverse.push(remaining.splice(index, 1)[0]); + } + for (const result of remaining) { + if (diverse.length >= maxResults) break; + diverse.push(result); + } + const explained = [...conflicts.values()] + .sort((a, b) => b.count - a.count) + .map((row) => ({ ...row, days: [...row.days].sort() })); + return { results: diverse, visited, truncated, diagnostics: { blockedGroups, conflicts: explained } }; } diff --git a/tests/app-generator.test.js b/tests/app-generator.test.js index cfa8765..f4455a4 100644 --- a/tests/app-generator.test.js +++ b/tests/app-generator.test.js @@ -14,6 +14,24 @@ const MATH = entry("MATH", "1151", "Calculus 1", [ taught(2001, ["tuesday", "thursday"], "10:00 AM", "10:55 AM", ["Diana Ikenberry Kline"]), taught(2002, ["monday", "wednesday"], "10:00 AM", "10:55 AM", ["Diana Ikenberry Kline"]), ]); +const ENGLISH = [ + entry("ENGLISH", "1110.01", "First-Year English Composition", [ + taught(3001, [], null, null, ["Online Teacher"], { instructionMode: "Distance Learning" }), + ]), + entry("ENGLISH", "1110.02", "First-Year English Composition", [ + taught(3002, [], null, null, ["Online Teacher"], { instructionMode: "Distance Learning" }), + ]), +]; +const COURSE_INDEX = { + terms: { [TERM]: { subjects: [ + { code: "CSE", name: "Computer Science and Engineering", courses: [["2221", "Software 1", 4, 4]] }, + { code: "MATH", name: "Mathematics", courses: [["1151", "Calculus 1", 5, 5]] }, + { code: "ENGLISH", name: "English", courses: [ + ["1110.01", "First-Year English Composition", 3, 3], + ["1110.02", "First-Year English Composition", 3, 3], + ] }, + ] } }, +}; function serve() { return stubFetch([ @@ -21,10 +39,11 @@ function serve() { [/\/classes\/search/, (raw) => { const url = new URL(raw); const subject = url.searchParams.get("subject")?.toUpperCase(); - const courses = subject === "CSE" ? [CSE] : subject === "MATH" ? [MATH] : []; + const courses = subject === "CSE" ? [CSE] : subject === "MATH" ? [MATH] : subject === "ENGLISH" ? ENGLISH : []; return { data: { totalItems: courses.length, totalPages: 1, courses } }; }], ["data/ratings.json", RATINGS], + ["data/courses.json", COURSE_INDEX], ["data/seats.json", SEATS_INDEX], [`data/seats-${TERM}.json`, SEATS_TERMS[TERM]], ["data/grades.json", { ok: false, status: 404, json: async () => null }], @@ -43,6 +62,7 @@ test("the schedule view generates and adopts a ranked multi-course schedule", as for (const code of ["CSE 2221", "MATH 1151"]) { page.el("#gen-course").value = code; fire(page.el(".gen-add"), "submit"); + await until(() => page.all(".gen-course-chip").length >= (code.startsWith("CSE") ? 1 : 2), `${code} to be added`); } assert.equal(page.all(".gen-course-chip").length, 2); @@ -52,8 +72,31 @@ test("the schedule view generates and adopts a ranked multi-course schedule", as assert.doesNotMatch(page.el(".gen-result").textContent, /Sections 1101.*Sections 2002/s, "the overlapping pair is not offered together"); + page.el(".gen-pin").click(); + await until(() => page.el(".gen-pin.is-pinned"), "the pinned course to be regenerated"); + assert.equal(page.el(".gen-pin.is-pinned").getAttribute("aria-pressed"), "true"); + page.el(".gen-use").click(); assert.equal(page.all(".plan-item").length, 2); assert.equal(page.el("#schedule-count").textContent, "2"); assert.match(page.el("#status").textContent, /Using generated schedule with 2 courses/); }); + +test("an unsuffixed course can select any decimal version, including asynchronous online sections", async (t) => { + t.after(serve()); + const page = await mountApp(); + await until(() => page.el("#term").options.length, "the term list"); + page.el("#view-schedule").click(); + page.el(".plan-generate").click(); + + for (const [index, code] of ["ENGLISH 1110", "CSE 2221"].entries()) { + page.el("#gen-course").value = code; + fire(page.el(".gen-add"), "submit"); + await until(() => page.all(".gen-course-chip").length === index + 1, `${code} to be added`); + } + assert.match(page.all(".gen-course-chip")[0].textContent, /ENGLISH 1110 \(any version\)/); + fire(page.el(".gen-preferences"), "submit"); + await until(() => page.el(".gen-result"), "a schedule with a decimal English version"); + assert.match(page.el(".gen-result").textContent, /ENGLISH 1110\.0[12]/); + assert.match(page.el(".gen-result").textContent, /Time to be announced/); +}); diff --git a/tests/courses.test.js b/tests/courses.test.js index 0750d88..68cd1e4 100644 --- a/tests/courses.test.js +++ b/tests/courses.test.js @@ -9,7 +9,15 @@ const INDEX = { 1268: { subjects: [ { code: "MATH", name: "Mathematics", courses: [["1151", "Calculus I", 5, 5], ["1172", "Engineering Calculus", 5, 5]] }, - { code: "CSE", name: "Computer Science and Engineering", courses: [["2221", "Software I", 4, 4]] }, + { code: "CSE", name: "Computer Science and Engineering", courses: [ + ["2221", "Software I", 4, 4], + ["2421", "Systems I: Low-Level Programming", 4, 4], + ["2431", "Systems II: Operating Systems", 3, 3], + ] }, + { code: "ENGLISH", name: "English", courses: [ + ["1110.01", "First-Year English Composition", 3, 3], + ["1110.02", "First-Year English Composition", 3, 3], + ] }, // 15 subjects upstream have no name of their own. { code: "CYBRSEC", name: "CYBRSEC", courses: [] }, { code: "NONAME", courses: [] }, @@ -52,7 +60,7 @@ test("regression #79: a failed loadCourses is not cached, so the next call retri }); test("subjectsFor sorts by code and is scoped to one term", () => { - assert.deepEqual(courses.subjectsFor("1268").map((s) => s.code), ["CSE", "CYBRSEC", "MATH", "NONAME"]); + assert.deepEqual(courses.subjectsFor("1268").map((s) => s.code), ["CSE", "CYBRSEC", "ENGLISH", "MATH", "NONAME"]); assert.deepEqual(courses.subjectsFor(1262).map((s) => s.code), ["CSE"]); assert.deepEqual(courses.subjectsFor("9999"), []); }); @@ -81,7 +89,7 @@ test("coursesFor unpacks the compact course tuples", () => { }); test("coursesFor accepts a lowercase code and is empty for an unknown one", () => { - assert.equal(courses.coursesFor("1268", "cse").length, 1); + assert.equal(courses.coursesFor("1268", "cse").length, 3); assert.deepEqual(courses.coursesFor("1268", "NOPE"), []); assert.deepEqual(courses.coursesFor("1268", null), []); assert.deepEqual(courses.coursesFor("1268", "CYBRSEC"), []); @@ -95,3 +103,18 @@ test("codeFromInput pulls the bare code out of a picker label", () => { assert.equal(courses.codeFromInput(""), ""); assert.equal(courses.codeFromInput(null), ""); }); + +test("course suggestions find partial numbers and title words", () => { + assert.deepEqual(courses.courseSuggestions("1268", "CSE 24").map((item) => item.code), [ + "CSE 2421", "CSE 2431", + ]); + assert.equal(courses.courseSuggestions("1268", "systems")[0].code, "CSE 2421"); + assert.match(courses.courseSuggestions("1268", "CSE 2421")[0].title, /Systems I/); +}); + +test("an absent base number offers an explicit decimal family and its variants", () => { + const found = courses.courseSuggestions("1268", "ENGLISH 1110"); + assert.deepEqual(found.map((item) => item.code), ["ENGLISH 1110", "ENGLISH 1110.01", "ENGLISH 1110.02"]); + assert.deepEqual(found[0].codes, ["ENGLISH 1110.01", "ENGLISH 1110.02"]); + assert.equal(found[0].family, true); +}); diff --git a/tests/generator-render.test.js b/tests/generator-render.test.js index 7f11301..fcb595a 100644 --- a/tests/generator-render.test.js +++ b/tests/generator-render.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { setupDom, fire } from "./dom.js"; +import { setupDom, fire, until } from "./dom.js"; import { renderGenerator } from "../js/generator-render.js"; import { entry, taught } from "./fixtures.js"; @@ -15,15 +15,19 @@ function options(overrides = {}) { }; } -test("the builder exposes desired courses, constraints and all ranking priorities", () => { +test("the builder exposes clear constraints and only available ranking priorities", () => { const page = setupDom(); const view = renderGenerator(options()); page.document.body.append(view); assert.equal(view.querySelectorAll(".gen-course-chip").length, 2); - assert.equal(view.querySelectorAll(".gen-weight input").length, 7); + assert.equal(view.querySelectorAll(".gen-weight input").length, 5); assert.match(view.textContent, /Open seats/); - assert.match(view.textContent, /Grade history/); + assert.match(view.textContent, /RateMyProfessors rating/); assert.match(view.textContent, /Shorter gaps/); + assert.match(view.textContent, /Only open classes/); + assert.match(view.textContent, /Online \/ distance includes classes with no set meeting time/); + assert.doesNotMatch(view.textContent, /Grade history|Fewer campus days/); + assert.equal(view.querySelectorAll(".gen-modes input").length, 4); assert.equal(view.querySelector(".gen-generate").disabled, false); }); @@ -36,8 +40,27 @@ test("submitting preferences returns the visible bounds and weights", () => { assert.equal(received.earliest, "09:00"); assert.equal(received.latest, "17:00"); assert.equal(received.excludeFull, true); + assert.deepEqual(received.allowedModes, ["inPerson", "hybrid", "distance", "arranged"]); assert.equal(received.weights.seats, 1); - assert.equal(Object.keys(received.weights).length, 7); + assert.equal(Object.keys(received.weights).length, 5); +}); + +test("course suggestions show course names and add the selected choice", async () => { + const page = setupDom(); + let added = null; + const choice = { key: "CSE 2421", code: "CSE 2421", title: "Systems I", codes: ["CSE 2421"] }; + const view = renderGenerator(options({ + onSuggest: async () => [choice], + onAdd: (value) => { added = value; return true; }, + })); + page.document.body.append(view); + const input = view.querySelector("#gen-course"); + input.value = "CSE 24"; + fire(input, "input"); + await until(() => view.querySelector(".gen-suggestion"), "course suggestions"); + assert.match(view.querySelector(".gen-suggestion").textContent, /CSE 2421Systems I/); + view.querySelector(".gen-suggestion").click(); + assert.equal(added, choice); }); test("a ranked result explains its evidence and can be adopted", () => { @@ -45,15 +68,26 @@ test("a ranked result explains its evidence and can be adopted", () => { const cse = entry("CSE", "2221", "Software 1", [taught(1001, ["monday"], "10:00 AM", "10:55 AM", ["Ada Teacher"])]); const result = { score: 84.4, - packages: [{ course: cse.course, sections: cse.sections }], - metrics: { minSeats: 8, rating: 4.2, gpa: 3.5, days: 3, gaps: 55, averageStart: 600, averageEnd: 900 }, + packages: [{ course: cse.course, sections: cse.sections, desiredKey: "CSE 2221", packageKey: "1001" }], + metrics: { minSeats: 8, rating: 4.2, gaps: 55, averageStart: 600, averageEnd: 900 }, + factors: { seats: 80, rating: 80, lateStart: 30, earlyEnd: 60, compactGaps: 88 }, }; let used = null; - const view = renderGenerator(options({ results: [result], onUse: (value) => { used = value; } })); + let pinned = null; + const view = renderGenerator(options({ + results: [result], + preferences: { earliest: "09:00", latest: "17:00", excludeFull: true, weights: { seats: 1, rating: 1, lateStart: 1, earlyEnd: 1, compactGaps: 1 } }, + onUse: (value) => { used = value; }, + onPin: (value) => { pinned = value; }, + })); page.document.body.append(view); - assert.match(view.querySelector(".gen-result").textContent, /84 match/); + assert.match(view.querySelector(".gen-result").textContent, /Best fit/); + assert.doesNotMatch(view.querySelector(".gen-result").textContent, /84 match|historical GPA|campus days/); + assert.match(view.querySelector(".gen-factors").textContent, /Shorter gaps: strong/); assert.match(view.querySelector(".gen-result").textContent, /8minimum seats/); assert.match(view.querySelector(".gen-result").textContent, /Sections 1001/); + view.querySelector(".gen-pin").click(); + assert.equal(pinned, result.packages[0]); view.querySelector(".gen-use").click(); assert.equal(used, result); }); diff --git a/tests/generator.test.js b/tests/generator.test.js index 7f253cb..2f45f9a 100644 --- a/tests/generator.test.js +++ b/tests/generator.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { describePackage, expandCoursePackages, generateSchedules, scoreCombination } from "../js/generator.js"; +import { + describePackage, expandCoursePackages, generateSchedules, instructionModeOf, packageKey, scoreCombination, +} from "../js/generator.js"; import { entry, taught } from "./fixtures.js"; const TERM = "1268"; @@ -10,11 +12,10 @@ function linked(rows) { return (number) => rows[String(number)] ?? null; } -function providers(seats = {}, ratings = {}, grades = {}) { +function providers(seats = {}, ratings = {}) { return { seatsFor: (number) => seats[String(number)] ?? null, ratingFor: (name) => ratings[name] ?? null, - gradesFor: (person) => grades[person.name] ?? null, }; } @@ -71,15 +72,20 @@ test("package evidence uses the scarcest linked section and ignores thin ratings const pkg = describePackage({ course: found.course, sections: found.sections }, TERM, providers( { 1001: { enrolled: 30, limit: 40 }, 1011: { enrolled: 19, limit: 20 } }, { "Ada Teacher": { avgRating: 4.5, numRatings: 20 }, "Thin Teacher": { avgRating: 5, numRatings: 1 } }, - { "Ada Teacher": { gpa: 3.4, n: 80 } }, )); assert.equal(pkg.minSeats, 1); assert.equal(pkg.hasFull, false); assert.equal(pkg.rating, 4.5); - assert.equal(pkg.gpa, 3.4); assert.equal(pkg.ranges.length, 2); }); +test("instruction modes distinguish asynchronous online sections from arranged meetings", () => { + assert.equal(instructionModeOf({ instructionMode: "Distance Learning", meetings: [] }), "distance"); + assert.equal(instructionModeOf({ instructionMode: "Hybrid Delivery", meetings: [] }), "hybrid"); + assert.equal(instructionModeOf({ instructionMode: "In Person", meetings: [] }), "inPerson"); + assert.equal(instructionModeOf({ meetings: [] }), "arranged"); +}); + test("a known full linked section is excluded even when another has unknown seats", () => { const found = entry("CSE", "2221", "Software 1", [ taught(1001, ["monday"], "9:00 AM", "9:55 AM", ["Ada Teacher"]), @@ -113,7 +119,7 @@ function candidate(subject, number, classNumber, days, start, end, metrics = {}) })))), minSeats: metrics.minSeats ?? 5, rating: metrics.rating ?? 4, - gpa: metrics.gpa ?? 3, + modes: metrics.modes ?? ["inPerson"], }; } @@ -147,7 +153,7 @@ test("earliest and latest bounds apply to every linked meeting", () => { assert.deepEqual(generated.results.map((result) => result.packages[0].sections[0].classNumber), [1002]); }); -test("ranking rewards seats, evidence and compact schedules", () => { +test("ranking rewards seats, instructor ratings and compact schedules", () => { const strong = [ candidate("CSE", "2221", 1001, ["monday"], "10:00", "11:00", { minSeats: 10, rating: 4.5, gpa: 3.7 }), candidate("MATH", "1151", 2001, ["monday"], "11:00", "12:00", { minSeats: 8, rating: 4.2, gpa: 3.5 }), @@ -161,6 +167,50 @@ test("ranking rewards seats, evidence and compact schedules", () => { assert.equal(scoreCombination(strong).metrics.gaps, 0); }); +test("instruction-mode filters and pins constrain complete packages", () => { + const inPerson = candidate("CSE", "2221", 1001, ["monday"], "10:00", "11:00"); + const distance = candidate("CSE", "2221", 1002, [], "10:00", "11:00", { modes: ["distance"] }); + const group = { key: "CSE 2221", course: inPerson.course, packages: [inPerson, distance] }; + + assert.deepEqual(generateSchedules([group], { allowedModes: ["distance"] }).results + .map((result) => result.packages[0].sections[0].classNumber), [1002]); + assert.deepEqual(generateSchedules([group], { pins: { "CSE 2221": packageKey(inPerson.sections) } }).results + .map((result) => result.packages[0].sections[0].classNumber), [1001]); +}); + +test("empty results explain blocking constraints and course conflicts", () => { + const full = candidate("CSE", "2221", 1001, ["monday"], "10:00", "11:00", { minSeats: 0 }); + const blocked = generateSchedules([{ key: "CSE 2221", label: "CSE 2221", packages: [full] }]); + assert.equal(blocked.diagnostics.blockedGroups[0].removed.full, 1); + + const cse = candidate("CSE", "2221", 1002, ["monday"], "10:00", "11:00"); + const math = candidate("MATH", "1151", 2001, ["monday"], "10:00", "11:00"); + cse.desiredLabel = "CSE 2221"; + math.desiredLabel = "MATH 1151"; + const conflict = generateSchedules([ + { key: "CSE 2221", packages: [cse] }, + { key: "MATH 1151", packages: [math] }, + ]); + assert.deepEqual(conflict.diagnostics.conflicts[0], { + a: "CSE 2221", b: "MATH 1151", count: 1, days: ["monday"], + }); +}); + +test("ranked results prefer alternatives that change more than one course", () => { + const groups = ["CSE", "MATH", "STAT"].map((subject, groupIndex) => ({ + key: `${subject} 1000`, + packages: [0, 1].map((variant) => candidate( + subject, "1000", groupIndex * 10 + variant, + [groupIndex === 0 ? "monday" : groupIndex === 1 ? "tuesday" : "wednesday"], + `${9 + variant}:00`, `${10 + variant}:00`, { minSeats: 10 - variant }, + )), + })); + const generated = generateSchedules(groups, { maxResults: 2 }); + const changed = generated.results[0].packages.filter((pkg, index) => + packageKey(pkg.sections) !== packageKey(generated.results[1].packages[index].sections)).length; + assert.ok(changed >= 2); +}); + test("generation reports when its work budget truncates the search", () => { const groups = ["CSE", "MATH", "STAT"].map((subject, i) => ({ course: { subject, catalogNumber: "1000" },