diff --git a/dist/index.html b/dist/index.html index 16cbb35..906634f 100644 --- a/dist/index.html +++ b/dist/index.html @@ -283,6 +283,10 @@

第 {{ currentScheduleIdx + 1 }} 方案课表

周平均学时: {{ schedules[currentScheduleIdx].stats?.avg_weekly_hours }} 活跃周次: {{ schedules[currentScheduleIdx].stats?.week_span }} +
+ ⚠️ 未排课程 (冲突): {{ schedules[currentScheduleIdx].missing_course_names.join(', ') }} +
评分详情: 基准分: 100 diff --git a/dist/static/js/solver.js b/dist/static/js/solver.js index c8d960b..0e14762 100644 --- a/dist/static/js/solver.js +++ b/dist/static/js/solver.js @@ -164,20 +164,6 @@ class ScheduleSolver { }); } - static coursesConflict(courseA, courseB) { - const bmpA = ScheduleSolver.parseBitmap(courseA.schedule_bitmaps); - const bmpB = ScheduleSolver.parseBitmap(courseB.schedule_bitmaps); - const length = Math.min(bmpA.length, bmpB.length); - - for (let w = 1; w < length; w++) { - if ((bmpA[w] & bmpB[w]) !== 0n) { - return true; - } - } - return false; - } - - // Check conflicts but return details (simplified for JS port, mostly for frontend use if needed) static coursesConflictWithDetails(courseA, courseB) { const bmpA = ScheduleSolver.parseBitmap(courseA.schedule_bitmaps); const bmpB = ScheduleSolver.parseBitmap(courseB.schedule_bitmaps); @@ -231,53 +217,63 @@ class ScheduleSolver { } if (allConflict) { - conflicts.push({ group1: i, group2: j, reason: firstReason || "Unknown" }); + const nameA = activeA[0].name || `Group ${i}`; + const nameB = activeB[0].name || `Group ${j}`; + return { error: `检测到绝对冲突: [${nameA}] 与 [${nameB}] 无法同时选择 (冲突原因: ${firstReason})` }; } } } - return conflicts; + return null; } static generateSchedules(groups, preferences) { const maxResults = 20; preferences = preferences || {}; - // 0. Preprocess: Merge Groups + // 0. Preprocess: Filter Empty Groups & Merge const mergedGroupsMap = new Map(); - for (const g of groups) { - const candidates = g.candidates || []; - if (candidates.length === 0) continue; + // Use filtered groups for logic + const nonEmptyGroups = groups.filter(g => g.candidates && g.candidates.some(c => c.selected)); - let courseName = candidates[0].name; + if (nonEmptyGroups.length === 0) { + return { schedules: [], total_found: 0 }; + } + + // 1. Check for Absolute Conflicts First + const conflictErr = ScheduleSolver.checkConflicts(nonEmptyGroups); + if (conflictErr) { + return { schedules: [], total_found: 0, error: conflictErr.error }; + } + + // 2. Prepare Meta-Groups + for (const g of nonEmptyGroups) { + const active = g.candidates.filter(c => c.selected); + let courseName = active[0].name; if (!courseName) courseName = `__ID_${g.id}__`; if (!mergedGroupsMap.has(courseName)) { mergedGroupsMap.set(courseName, { id: g.id, - candidates: candidates.filter(c => c.selected) + name: courseName, + candidates: active }); } else { const existing = mergedGroupsMap.get(courseName); - const newActive = candidates.filter(c => c.selected); - existing.candidates = existing.candidates.concat(newActive); + existing.candidates = existing.candidates.concat(active); } } - const processedGroups = Array.from(mergedGroupsMap.values()); - - // 1. Meta-Candidates const metaGroups = []; - for (const g of processedGroups) { + for (const g of mergedGroupsMap.values()) { const active = g.candidates; - if (active.length === 0) return { schedules: [], total_found: 0, error: "One of the groups has no selected candidates." }; + // Cluster by time slots (bitmaps) const clusters = new Map(); for (const c of active) { const rawBm = c.schedule_bitmaps || []; const intBm = ScheduleSolver.parseBitmap(rawBm); - // Create key from bitmap content - const key = intBm.join(','); // Array to string key + const key = intBm.join(','); if (!clusters.has(key)) { clusters.set(key, { bitmaps: intBm, list: [] }); @@ -294,58 +290,131 @@ class ScheduleSolver { }); } - // Sort by density (fewest bits set) + // Sort by density (heuristic: fewer classes first might leave more room? or opposite?) + // Just stick to density sort metaCandidates.sort((a, b) => { const countA = a.bitmaps.reduce((acc, val) => acc + ScheduleRanker.countSetBits(val), 0); const countB = b.bitmaps.reduce((acc, val) => acc + ScheduleRanker.countSetBits(val), 0); return countA - countB; }); - metaGroups.push(metaCandidates); + metaGroups.push({ + name: g.name, + candidates: metaCandidates + }); } - // Sort groups by size (MRV) - metaGroups.sort((a, b) => a.length - b.length); + // Sort groups by size (MRV - Minimum Remaining Values) to fail fast + metaGroups.sort((a, b) => a.candidates.length - b.candidates.length); - // 2. DFS - const topNHeap = []; // Array of {score, schedule} + const totalGroupsCount = metaGroups.length; + + // 3. Max-Subset DFS + const topNHeap = []; + let maxCoursesFound = 0; let totalFound = 0; const currentBitmap = Array(30).fill(0n); function backtrack(groupIdx, currentScheduleMeta) { - if (groupIdx === metaGroups.length) { - totalFound++; - - // Reconstruct - const finalSchedule = currentScheduleMeta.map(m => { - const rep = { ...m.representative }; // Shallow copy - rep.alternatives = m.alternatives; - return rep; - }); + const scheduledCount = currentScheduleMeta.length; + + // Base Case: All groups processed + if (groupIdx === totalGroupsCount) { + // We reached a leaf. Update maxCoursesFound + if (scheduledCount > maxCoursesFound) { + maxCoursesFound = scheduledCount; + // Clear heap because we found a better size? + // Usually we prefer larger schedules over higher scores of smaller schedules. + // Yes: "must arrange as many courses as possible first" + topNHeap.length = 0; + } - const score = ScheduleRanker.scoreSchedule(finalSchedule, preferences); - const entry = { score, schedule: finalSchedule }; + if (scheduledCount === maxCoursesFound) { + totalFound++; + + // Reconstruct + const finalSchedule = currentScheduleMeta.map(m => { + const rep = { ...m.representative }; + rep.alternatives = m.alternatives; + return rep; + }); + + // Missing Groups + const presentNames = new Set(finalSchedule.map(c => c.name)); + const missingNames = []; + for(const mg of metaGroups) { + if (!presentNames.has(mg.name)) { + missingNames.push(mg.name); + } + } - if (topNHeap.length < maxResults) { - topNHeap.push(entry); - topNHeap.sort((a, b) => a.score - b.score); // Ascending order (min-heap like) - } else if (score > topNHeap[0].score) { - topNHeap[0] = entry; - topNHeap.sort((a, b) => a.score - b.score); + const missingCount = missingNames.length; + + // Score + const evalResult = ScheduleRanker.evaluateSchedule(finalSchedule, preferences); + let score = evalResult.score; + + // Apply Missing Penalty + const penalty = missingCount * 10.0; + score -= penalty; + + const entry = { + score, + schedule: finalSchedule, + missingNames, + missingCount, + details: evalResult.details + }; + // Add missing penalty to details for display + if (missingCount > 0) { + entry.details['缺课惩罚'] = -penalty; + } + + if (topNHeap.length < maxResults) { + topNHeap.push(entry); + topNHeap.sort((a, b) => a.score - b.score); + } else if (score > topNHeap[0].score) { + topNHeap[0] = entry; + topNHeap.sort((a, b) => a.score - b.score); + } } return; } - // Pruning (Optional) - /*if (topNHeap.length === maxResults) { - const partialSched = currentScheduleMeta.map(m => m.representative); - const partialScore = ScheduleRanker.scoreSchedule(partialSched, preferences); - if (partialScore < topNHeap[0].score - 50) return; - }*/ + // Pruning: Calculate Future Potential + // Count how many future groups have AT LEAST ONE candidate compatible with currentBitmap + let compatibleFuture = 0; + for (let i = groupIdx; i < totalGroupsCount; i++) { + const group = metaGroups[i]; + let canFit = false; + for (const cand of group.candidates) { + // Check compatibility + let ok = true; + const limit = Math.min(cand.bitmaps.length, currentBitmap.length); + for (let w = 1; w < limit; w++) { + if ((cand.bitmaps[w] & currentBitmap[w]) !== 0n) { + ok = false; + break; + } + } + if (ok) { + canFit = true; + break; + } + } + if (canFit) compatibleFuture++; + } - const candidates = metaGroups[groupIdx]; + if (scheduledCount + compatibleFuture < maxCoursesFound) { + // Cannot possibly beat the best found size + return; + } - for (const meta of candidates) { + // Branch 1: Try to pick a candidate from current group + const currentGroup = metaGroups[groupIdx]; + let pickedSomething = false; + + for (const meta of currentGroup.candidates) { const metaBmp = meta.bitmaps; // Check Conflict @@ -359,57 +428,55 @@ class ScheduleSolver { } if (!isValid) continue; - //forward check - let futureIsDead = false; - - for (let nextG = groupIdx + 1; nextG < metaGroups.length; nextG++) { - let nextCandidate = false; - - for (const nextMeta of metaGroups[nextG]) { - let conflictFound = false; - const limitF = Math.min(nextMeta.bitmaps.length, currentBitmap.length); - // Apply - for (let w = 1; w < limitF; w++) { - if (((currentBitmap[w] | metaBmp[w]) & nextMeta.bitmaps[w]) !== 0n) { - conflictFound = true; - break; - } - } - if (!conflictFound) { - nextCandidate = true; - break; - } - } - if (!nextCandidate) { - futureIsDead = true; - break; - } - } - - if (futureIsDead) continue; + // Optimization: Future Lookahead (Dead End Check) + // If picking this candidate reduces future compatible groups such that we can't reach maxCoursesFound? + // This is expensive to check fully, but let's do a light check if needed. + // The main pruning above handles the "Skip" branch logic. + // Here we just proceed. currentScheduleMeta.push(meta); + // Update bitmap + for (let w = 1; w < limit; w++) { + currentBitmap[w] |= metaBmp[w]; + } backtrack(groupIdx + 1, currentScheduleMeta); - // Undo + // Backtrack currentScheduleMeta.pop(); for (let w = 1; w < limit; w++) { - currentBitmap[w] ^= metaBmp[w]; // XOR to unset + currentBitmap[w] ^= metaBmp[w]; // Unset } + pickedSomething = true; } + + // Branch 2: Skip this group (allow missing) + // We only skip if we HAVE to? No, user might want to skip a specific group to fit others? + // "Must arrange as many courses as possible". + // If we found valid candidates above (pickedSomething), we usually don't want to skip THIS group *unless* skipping it allows us to pick MORE future groups. + // But if we explored all valid candidates above, `backtrack` recursed. + // Do we also explore the "Skip" branch? + // Yes, because maybe skipping this one (even if it fits) allows 2 others to fit later. + // However, if we picked a candidate, `scheduledCount` increased. + // If we skip, `scheduledCount` stays same. + // We should always explore skipping unless we are sure we don't need to. + // Given the complexity, let's explore skipping. + // BUT to optimize: if we successfully picked a candidate, we might assume it's better than skipping IF conflicts are rare. + // With "Absolute Conflict" already handled, conflicts are subtler. + // To ensure GLOBAL maximum, we must allow skipping. + + backtrack(groupIdx + 1, currentScheduleMeta); } backtrack(0, []); - // Sort descending by score for output + // Sort descending const sortedResults = topNHeap.sort((a, b) => b.score - a.score); - // Enrich results with stats + // Map to final format const finalSchedules = sortedResults.map(item => { const sched = item.schedule; - const evalResult = ScheduleRanker.evaluateSchedule(sched, preferences); let totalCredits = 0; let totalHours = 0; @@ -423,20 +490,18 @@ class ScheduleSolver { } }); - // Calculate span const weeks = Array.from(weekSet).sort((a,b)=>a-b); let weekSpan = ""; if (weeks.length > 0) { - // simple grouping - // ... logic to format 1-16 etc. - // Just using start-end for now weekSpan = `${weeks[0]}-${weeks[weeks.length-1]}`; } return { score: item.score, - score_details: evalResult.details, + score_details: item.details, courses: sched, + missing_course_names: item.missingNames, + missing_groups: [], // user didn't ask for IDs, just names in UI. stats: { total_credits: totalCredits, total_hours: totalHours, @@ -453,7 +518,7 @@ class ScheduleSolver { } } -// Export for module use or browser global +// Export if (typeof window !== 'undefined') { window.Solver = ScheduleSolver; window.Ranker = ScheduleRanker; diff --git a/server.log b/server.log index 9014859..56c17b2 100644 --- a/server.log +++ b/server.log @@ -1,6 +1,22 @@ -127.0.0.1 - - [10/Jan/2026 14:12:32] "GET /index.html HTTP/1.1" 200 - -127.0.0.1 - - [10/Jan/2026 14:12:32] "GET /static/vue.global.js HTTP/1.1" 200 - -127.0.0.1 - - [10/Jan/2026 14:12:32] "GET /static/html2canvas.min.js HTTP/1.1" 200 - -127.0.0.1 - - [10/Jan/2026 14:12:32] "GET /static/js/solver.js HTTP/1.1" 200 - -127.0.0.1 - - [10/Jan/2026 14:12:32] "GET /static/js/app.js HTTP/1.1" 200 - -127.0.0.1 - - [10/Jan/2026 14:12:33] "GET /functions/search?name=%E6%95%B0%E5%AD%A6&code=&campus=1&semester=2025-2026-1&match_mode=OR HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/js/app.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/js/solver.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:36:31] code 404, message File not found +127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 404 - +127.0.0.1 - - [11/Jan/2026 04:37:37] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/js/app.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/js/solver.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:42:04] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/js/solver.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/js/app.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:43:33] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/js/app.js HTTP/1.1" 200 - +127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/js/solver.js HTTP/1.1" 200 - diff --git a/verification/spa_failed.png b/verification/spa_failed.png new file mode 100644 index 0000000..166a50c Binary files /dev/null and b/verification/spa_failed.png differ diff --git a/verification/spa_missing_courses.png b/verification/spa_missing_courses.png new file mode 100644 index 0000000..815928b Binary files /dev/null and b/verification/spa_missing_courses.png differ diff --git a/verification/test_solver_spa.js b/verification/test_solver_spa.js new file mode 100644 index 0000000..8990669 --- /dev/null +++ b/verification/test_solver_spa.js @@ -0,0 +1,122 @@ + +const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); + +// Load Solver +const solverCode = fs.readFileSync(path.join(__dirname, '../dist/static/js/solver.js'), 'utf8'); +const context = { window: {}, module: { exports: {} } }; +vm.runInNewContext(solverCode, context); + +// Try to get from window or module.exports +const ScheduleSolver = context.window.Solver || context.module.exports.ScheduleSolver; +const ScheduleRanker = context.window.Ranker || context.module.exports.ScheduleRanker; + +function runTest() { + console.log("Starting Solver Tests..."); + let failed = false; + + // Helper to create simple course candidate + const createCandidate = (name, weekBits) => { + // weekBits: array of 26 ints. + // We'll just set week 1 + const b = Array(30).fill(0n); + b[1] = BigInt(weekBits); + return { + name, + schedule_bitmaps: b.map(x=>x.toString()), // Solver expects strings often or BigInts + selected: true, + bitmaps: b // Mock what solver expects inside (wait, solver parses it from schedule_bitmaps) + }; + }; + + // Case 1: No Conflicts + { + console.log("Test Case 1: No Conflicts"); + const g1 = { id: 1, candidates: [createCandidate('A', 1)] }; + const g2 = { id: 2, candidates: [createCandidate('B', 2)] }; + const res = ScheduleSolver.generateSchedules([g1, g2], {}); + + if (res.error) { + console.error("FAIL: Unexpected error", res.error); + failed = true; + } else if (res.schedules.length === 0) { + console.error("FAIL: No schedules found"); + failed = true; + } else if (res.schedules[0].courses.length !== 2) { + console.error("FAIL: Expected 2 courses, got", res.schedules[0].courses.length); + failed = true; + } else if (res.schedules[0].missing_course_names && res.schedules[0].missing_course_names.length > 0) { + console.error("FAIL: Unexpected missing courses"); + failed = true; + } else { + console.log("PASS"); + } + } + + // Case 2: Absolute Conflict + { + console.log("Test Case 2: Absolute Conflict"); + const g1 = { id: 1, candidates: [createCandidate('A', 1)] }; + const g2 = { id: 2, candidates: [createCandidate('B', 1)] }; + const res = ScheduleSolver.generateSchedules([g1, g2], {}); + + if (res.error && res.error.includes("绝对冲突")) { + console.log("PASS: Caught absolute conflict:", res.error); + } else { + console.error("FAIL: Expected absolute conflict error, got:", res); + failed = true; + } + } + + // Case 3: Partial Conflict (Maximization) + { + console.log("Test Case 3: Partial Conflict (Maximization)"); + const g1 = { id: 1, candidates: [createCandidate('A1', 1), createCandidate('A2', 2)], name: "GroupA" }; + const g2 = { id: 2, candidates: [createCandidate('B', 1)], name: "GroupB" }; + const g3 = { id: 3, candidates: [createCandidate('C', 2)], name: "GroupC" }; + + // Ensure candidates have selected=true + g1.candidates.forEach(c => c.selected = true); + g2.candidates.forEach(c => c.selected = true); + g3.candidates.forEach(c => c.selected = true); + + const res = ScheduleSolver.generateSchedules([g1, g2, g3], {}); + + if (res.error) { + console.error("FAIL: Unexpected error in partial case", res.error); + failed = true; + } else if (res.schedules.length === 0) { + console.error("FAIL: No schedules found"); + failed = true; + } else { + const top = res.schedules[0]; + console.log("Top Schedule Score:", top.score); + console.log("Top Schedule Courses:", top.courses.length); + console.log("Missing:", top.missing_course_names); + + if (top.courses.length !== 2) { + console.error("FAIL: Expected 2 courses, got", top.courses.length); + failed = true; + } + if (!top.missing_course_names || top.missing_course_names.length !== 1) { + console.error("FAIL: Expected 1 missing course"); + failed = true; + } + // Check penalty + // Score starts at 100. + // Missing 1 course = -10. + // Expected score <= 90. + if (top.score > 90.0001) { + console.error("FAIL: Score too high, penalty not applied?"); + failed = true; + } else { + console.log("PASS"); + } + } + } + + if (failed) process.exit(1); +} + +runTest(); diff --git a/verification/verify_spa_frontend.py b/verification/verify_spa_frontend.py new file mode 100644 index 0000000..28834fc --- /dev/null +++ b/verification/verify_spa_frontend.py @@ -0,0 +1,95 @@ +from playwright.sync_api import sync_playwright + +def verify_missing_courses_display(page): + page.on("console", lambda msg: print(f"Browser Console: {msg.text}")) + page.goto("http://localhost:8080") + + # Mock Search + # Group A: 2 Options. Mon 1-2 (conflicts with B), Tue 1-2 (conflicts with C) + page.route("**/search?*name=A*", lambda route: route.fulfill( + status=200, + content_type="application/json", + body='[' + + '{"name": "A", "code": "001", "teacher": "T1a", "location_text": "Mon 1-2", "checked": false, "schedule_bitmaps": ["3","3"]},' + + '{"name": "A", "code": "001", "teacher": "T1b", "location_text": "Tue 1-2", "checked": false, "schedule_bitmaps": ["12","12"]}' + + ']' + )) + # Group B: Mon 1-2 (conflicts with A1) + page.route("**/search?*name=B*", lambda route: route.fulfill( + status=200, + content_type="application/json", + body='[{"name": "B", "code": "002", "teacher": "T2", "location_text": "Mon 1-2", "checked": false, "schedule_bitmaps": ["3","3"]}]' + )) + # Group C: Tue 1-2 (conflicts with A2) + page.route("**/search?*name=C*", lambda route: route.fulfill( + status=200, + content_type="application/json", + body='[{"name": "C", "code": "003", "teacher": "T3", "location_text": "Tue 1-2", "checked": false, "schedule_bitmaps": ["12","12"]}]' + )) + + # Add A (Both options) + page.fill('input[placeholder*="课程名"]', 'A') + page.click("button:has-text('搜索')") + page.wait_for_selector("text=结果 (2)") + # Select all + page.click("text=全选/反选") + page.click("button:has-text('将选中项存为一组')") + + # Add B + page.fill('input[placeholder*="课程名"]', 'B') + page.click("button:has-text('搜索')") + page.wait_for_selector("text=结果 (1)") + page.click("input[type='checkbox']") + page.click("button:has-text('将选中项存为一组')") + + # Add C + page.fill('input[placeholder*="课程名"]', 'C') + page.click("button:has-text('搜索')") + page.wait_for_selector("text=结果 (1)") + page.click("input[type='checkbox']") + page.click("button:has-text('将选中项存为一组')") + + # Go to Planning + page.click("text=2. 规划 & 策略") + page.wait_for_selector("text=我的课程组 (3)") + + # Generate + print("Clicking Generate...") + page.click("button:has-text('生成课表方案')") + + # Check for toast + try: + toast = page.wait_for_selector(".toast", state="visible", timeout=2000) + if toast: + print(f"Toast detected: {toast.inner_text()}") + except: + pass + + # Wait for result + try: + page.wait_for_selector("text=推荐方案", timeout=5000) + print("Results generated successfully.") + except: + print("Failed to reach results view.") + page.screenshot(path="verification/spa_failed.png") + return + + # Check warning + # We expect some course to be missing (either A, B, or C) + warning_locator = page.locator("text=未排课程 (冲突):") + if warning_locator.is_visible(): + print("PASS: Warning is visible") + print(warning_locator.text_content()) + else: + print("FAIL: Warning not found") + + page.screenshot(path="verification/spa_missing_courses.png") + +if __name__ == "__main__": + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + try: + verify_missing_courses_display(page) + finally: + browser.close()