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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,20 @@ function searchFromPickers() {
els.query.value = q;
pickedSearch = { q, subject: code };
syncUrl(q, els.term.value);
selectView("list");
runSearch(q, els.term.value, code);
}

function setView(next) {
function selectView(next) {
view = next;
for (const [button, name] of [[els.viewList, "list"], [els.viewCal, "calendar"], [els.viewSchedule, "schedule"]]) {
button.classList.toggle("is-on", name === next);
button.setAttribute("aria-pressed", String(name === next));
}
}

function setView(next) {
selectView(next);
if (next !== "schedule" && !lastResult) {
els.results.replaceChildren();
resetDetail();
Expand Down Expand Up @@ -1172,6 +1177,7 @@ async function init() {

els.gen.addEventListener("change", () => {
syncUrl(els.query.value, els.term.value);
selectView("list");
rerunSearch();
});

Expand Down Expand Up @@ -1278,6 +1284,9 @@ async function init() {
const q = els.query.value;
reflectQuery(q);
syncUrl(q, els.term.value);
// A submitted search is a request to see its results. Keeping Schedule
// selected completed the request invisibly until the student clicked List.
selectView("list");
runSearch(q, els.term.value);
});

Expand Down
31 changes: 24 additions & 7 deletions js/rank.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ import { instructorsOf } from "./format.js";
const SUBJECT_RE = /^[A-Z]{2,8}$/;
const NUMBER_RE = /^\d{3,4}(\.\d+)?[A-Z]*$/;

// A base catalog number names its decimal variants too: STAT 3470 is the
// family containing STAT 3470.01 and 3470.02. A query that names a variant is
// still exact, and suffixes such as 3470H are separate courses rather than
// members of the decimal family.
function catalogMatch(query, catalog) {
const wanted = String(query ?? "").toUpperCase();
const actual = String(catalog ?? "").toUpperCase();
if (!wanted || !actual) return 0;
if (actual === wanted) return 2;
if (/^\d{3,4}$/.test(wanted) && actual.startsWith(`${wanted}.`)) return 1;
return 0;
}

export function parseQuery(raw) {
const tokens = (raw ?? "").trim().toUpperCase().split(/\s+/).filter(Boolean);
return {
Expand Down Expand Up @@ -45,16 +58,18 @@ function scoreCourse({ course, sections }, parsed) {

let score = 0;
const subjectHit = parsed.subject && subject === parsed.subject;
const numberHit = parsed.number && number === parsed.number;
const numberMatch = parsed.number ? catalogMatch(parsed.number, number) : 0;
const numberHit = numberMatch === 2;

if (subjectHit && numberHit) score += 1000;
else if (subjectHit && numberMatch === 1) score += 900;
else {
if (subjectHit) score += 120;
if (numberHit) score += 90;
}

// A bare number query should still favour the course someone meant.
if (!parsed.subject && numberHit) score += 60;
if (!parsed.subject && numberMatch) score += numberHit ? 60 : 50;

for (const token of parsed.tokens) {
if (token === subject || token === number) continue;
Expand All @@ -69,9 +84,10 @@ function scoreCourse({ course, sections }, parsed) {
}
}

// Break ties toward the course people are more likely to want: more sections
// means a bigger, more commonly taken offering.
score += Math.min(sections.length, 30) / 100;
// Inside an explicitly requested course family, catalog order is the useful
// stable order (.01, .02, ...). For loose matches, prefer the larger, more
// commonly taken offering.
if (!(subjectHit && numberMatch)) score += Math.min(sections.length, 30) / 100;
return score;
}

Expand Down Expand Up @@ -121,12 +137,13 @@ export function filterCourses(entries, rawQuery) {
// requirement browse under "related" and then report 25 as the whole answer.
if (!parsed.tokens.length) return { primary: pool, related: setAside, reason: "all" };

// An exact subject plus number is unambiguous. Answer the question asked.
// A subject plus catalog number is unambiguous. An unsuffixed number names
// its decimal family, while a query that includes .01 or .02 stays exact.
if (parsed.subject && parsed.number) {
const exact = pool.filter(
(e) =>
(e.course.subject ?? "").toUpperCase() === parsed.subject &&
(e.course.catalogNumber ?? "").toUpperCase() === parsed.number
catalogMatch(parsed.number, e.course.catalogNumber) > 0
);
if (exact.length) {
const rest = [...pool.filter((e) => !exact.includes(e)), ...setAside];
Expand Down
17 changes: 17 additions & 0 deletions tests/app-schedule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ test("a shared class-number plan is refreshed and opens in schedule view", async
assert.match(page.el("#status").textContent, /refreshed/i);
});

test("submitting a course search from the schedule opens its list results", async (t) => {
t.after(serve());
const page = await mountApp();
await until(() => page.el("#term").options.length > 0, "the term list");

page.el("#view-schedule").click();
assert.equal(page.el("#view-schedule").getAttribute("aria-pressed"), "true");

page.el("#q").value = "CSE 2221";
fire(page.el("#search"), "submit");
await until(() => page.el('.section[data-class-number="1001"]'), "the searched course");

assert.equal(page.el("#view-list").getAttribute("aria-pressed"), "true");
assert.equal(page.el("#view-schedule").getAttribute("aria-pressed"), "false");
assert.equal(page.el(".app").dataset.view, "results");
});

test("a section with several valid linked components asks instead of guessing", async (t) => {
const queries = [];
t.after(serve(queries));
Expand Down
50 changes: 50 additions & 0 deletions tests/rank.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ test("rankCourses puts an exact subject and number first", () => {
);
});

test("rankCourses puts decimal variants of a requested base number first in catalog order", () => {
const ranked = rankCourses(
[
entry("STAT", "1350.01", "Elementary Statistics", Array.from({ length: 20 }, (_, i) => section(100 + i))),
entry("STAT", "3470.02", "Probability and Statistics", Array.from({ length: 10 }, (_, i) => section(200 + i))),
entry("STAT", "3470.01", "Probability and Statistics", [section(300)]),
],
"STAT 3470"
);
assert.deepEqual(ranked.map((e) => e.course.catalogNumber), ["3470.01", "3470.02", "1350.01"]);
});

test("rankCourses lifts a course because of who teaches it", () => {
const ranked = rankCourses(corpus(), "GOMORI");
assert.equal(ranked[0].course.catalogNumber, "2331");
Expand Down Expand Up @@ -188,6 +200,44 @@ test("filterCourses answers an exact subject and number with that course alone",
assert.equal(related.length, 5);
});

test("filterCourses treats an unsuffixed catalog query as its decimal family", () => {
const results = [
entry("STAT", "1350.01", "Elementary Statistics", [section(100)]),
entry("STAT", "3470.02", "Probability and Statistics", [section(200)]),
entry("STAT", "3470.01", "Probability and Statistics", [section(300)]),
entry("STAT", "3470H", "Honors Probability and Statistics", [section(400)]),
entry("STAT", "347", "Other Statistics", [section(500)]),
];
const { primary, related, reason } = filterCourses(results, "STAT 3470");

assert.equal(reason, "exact");
assert.deepEqual(primary.map((e) => e.course.catalogNumber), ["3470.01", "3470.02"]);
assert.deepEqual(related.map((e) => e.course.catalogNumber), ["1350.01", "347", "3470H"]);
});

test("filterCourses keeps a requested decimal variant exact", () => {
const results = [
entry("STAT", "3470.01", "Probability and Statistics", [section(100)]),
entry("STAT", "3470.02", "Probability and Statistics", [section(200)]),
];
const { primary, related, reason } = filterCourses(results, "STAT 3470.02");

assert.equal(reason, "exact");
assert.deepEqual(primary.map((e) => e.course.catalogNumber), ["3470.02"]);
assert.deepEqual(related.map((e) => e.course.catalogNumber), ["3470.01"]);
});

test("an exact base course precedes the decimal variants in its family", () => {
const results = [
entry("STAT", "3470.02", "Probability and Statistics", [section(100)]),
entry("STAT", "3470", "Probability and Statistics", [section(200)]),
entry("STAT", "3470.01", "Probability and Statistics", [section(300)]),
];
const { primary } = filterCourses(results, "STAT 3470");

assert.deepEqual(primary.map((e) => e.course.catalogNumber), ["3470", "3470.01", "3470.02"]);
});

test("filterCourses demotes independent study out of a subject search", () => {
const { primary, reason } = filterCourses(corpus(), "CSE");
assert.equal(reason, "ranked");
Expand Down
Loading