diff --git a/js/app.js b/js/app.js index b345dd7..9de79a5 100644 --- a/js/app.js +++ b/js/app.js @@ -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(); @@ -1172,6 +1177,7 @@ async function init() { els.gen.addEventListener("change", () => { syncUrl(els.query.value, els.term.value); + selectView("list"); rerunSearch(); }); @@ -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); }); diff --git a/js/rank.js b/js/rank.js index 9320a6f..32103a6 100644 --- a/js/rank.js +++ b/js/rank.js @@ -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 { @@ -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; @@ -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; } @@ -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]; diff --git a/tests/app-schedule.test.js b/tests/app-schedule.test.js index 3e12deb..c504ee9 100644 --- a/tests/app-schedule.test.js +++ b/tests/app-schedule.test.js @@ -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)); diff --git a/tests/rank.test.js b/tests/rank.test.js index 4c00a11..b57a35d 100644 --- a/tests/rank.test.js +++ b/tests/rank.test.js @@ -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"); @@ -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");