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()