diff --git a/dist/static/js/solver.js b/dist/static/js/solver.js index 2ce51a3..8414558 100644 --- a/dist/static/js/solver.js +++ b/dist/static/js/solver.js @@ -269,10 +269,59 @@ class ScheduleSolver { // 0. Preprocess: Filter Empty Groups & Merge const mergedGroupsMap = new Map(); + // Collect names of all groups the user INTENDED to schedule (before filtering invalid candidates) + // This ensures that if a group is completely filtered out (e.g. all candidates are 'Ghost'/Invalid), + // it is still counted as a "Missing Course". + const allRequiredNames = new Set(); + groups.forEach(g => { + if (g.candidates && g.candidates.some(c => c.selected)) { + // Use the name of the first selected candidate, or the first candidate as fallback + const active = g.candidates.find(c => c.selected) || g.candidates[0]; + if (active && active.name) { + allRequiredNames.add(active.name); + } else { + allRequiredNames.add(`__ID_${g.id}__`); + } + } + }); + + // Helper to validate a candidate + const isValidCandidate = (c) => { + // Rule 1: Reject if location is "Free Time" + if ((c.location_text || "").includes("自由时间")) return false; + + // Rule 2: Check Time Parsing + // If time cannot be parsed (bitmap is empty or all zeros), treat as invalid immediately. + const bmps = ScheduleSolver.parseBitmap(c.schedule_bitmaps); + // Check if any week has a non-zero bitmap + let hasTime = false; + for (const b of bmps) { + if (b > 0n) { + hasTime = true; + break; + } + } + if (!hasTime) return false; + + return true; + }; + + // Filter out invalid candidates from the groups + // We create a shallow copy of groups to avoid mutating the input in a way that affects the UI permanently if not desired, + // but 'groups' passed here is usually a deep copy from app.js anyway. + // Let's iterate and filter candidates on the fly. + const cleanedGroups = groups.map(g => { + return { + ...g, + candidates: g.candidates.filter(c => c.selected && isValidCandidate(c)) + }; + }); + // Use filtered groups for logic - const nonEmptyGroups = groups.filter(g => g.candidates && g.candidates.some(c => c.selected)); + // A group is 'nonEmpty' only if it still has valid, selected candidates. + const nonEmptyGroups = cleanedGroups.filter(g => g.candidates && g.candidates.length > 0); - if (nonEmptyGroups.length === 0) { + if (nonEmptyGroups.length === 0 && allRequiredNames.size === 0) { return { schedules: [], total_found: 0 }; } @@ -385,9 +434,11 @@ class ScheduleSolver { // 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); + + // Compare against ALL required names, not just the ones that made it into metaGroups + for(const reqName of allRequiredNames) { + if (!presentNames.has(reqName)) { + missingNames.push(reqName); } } @@ -398,7 +449,8 @@ class ScheduleSolver { let score = evalResult.score; // Apply Missing Penalty - const penalty = missingCount * 10.0; + // Increased penalty to ensure schedules with missing courses are ranked lower + const penalty = missingCount * 500.0; score -= penalty; const entry = { diff --git a/server.log b/server.log deleted file mode 100644 index 6055d81..0000000 --- a/server.log +++ /dev/null @@ -1,26 +0,0 @@ -127.0.0.1 - - [11/Jan/2026 08:58:47] "GET / HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:58:48] "GET /static/vue.global.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:58:48] "GET /static/js/solver.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:58:48] "GET /static/html2canvas.min.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:58:48] "GET /static/js/app.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:59:07] "GET / HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:59:07] "GET /static/vue.global.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:59:07] "GET /static/html2canvas.min.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:59:07] "GET /static/js/app.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 08:59:07] "GET /static/js/solver.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:25] "GET / HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/vue.global.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/html2canvas.min.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/js/solver.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/js/app.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:26] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:26] "GET / HTTP/1.1" 304 - -127.0.0.1 - - [11/Jan/2026 09:14:27] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:43] "GET / HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/vue.global.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/html2canvas.min.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/js/solver.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/js/app.js HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:44] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 - -127.0.0.1 - - [11/Jan/2026 09:14:44] "GET / HTTP/1.1" 304 - -127.0.0.1 - - [11/Jan/2026 09:14:44] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 - diff --git a/verification/interaction_test.png b/verification/interaction_test.png deleted file mode 100644 index 1dc4ffb..0000000 Binary files a/verification/interaction_test.png and /dev/null differ diff --git a/verification/server.py b/verification/server.py deleted file mode 100644 index 426ce5b..0000000 --- a/verification/server.py +++ /dev/null @@ -1,43 +0,0 @@ -import http.server -import socketserver -import json -import urllib.parse -import os - -PORT = 8000 -DIRECTORY = "dist" - -class Handler(http.server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=DIRECTORY, **kwargs) - - def do_GET(self): - parsed = urllib.parse.urlparse(self.path) - path = parsed.path - - if path == "/search": - query = urllib.parse.parse_qs(parsed.query) - # Dummy response for search - data = [] - for i in range(20): - data.append({ - "name": f"Course {i}", - "code": f"CODE{i}", - "teacher": f"Teacher {i}", - "location_text": f"Loc {i}", - "sessions": [], - "alternatives": [] - }) - - self.send_response(200) - self.send_header("Content-type", "application/json") - self.end_headers() - self.wfile.write(json.dumps(data).encode()) - return - - return super().do_GET() - -if __name__ == "__main__": - with socketserver.TCPServer(("", PORT), Handler) as httpd: - print(f"Serving at port {PORT}") - httpd.serve_forever() diff --git a/verification/spa_verified.png b/verification/spa_verified.png deleted file mode 100644 index 581cabc..0000000 Binary files a/verification/spa_verified.png and /dev/null differ diff --git a/verification/verify_interaction.py b/verification/verify_interaction.py deleted file mode 100644 index 3f0b161..0000000 --- a/verification/verify_interaction.py +++ /dev/null @@ -1,98 +0,0 @@ -from playwright.sync_api import sync_playwright, expect -import re - -def test_interaction(page): - page.goto("http://localhost:8000") - - # Wait for page to load - page.wait_for_selector(".search-grid") - - # 1. Perform Search - page.click("button:text('搜索')") - - # Wait for results - page.wait_for_selector(".result-item") - items = page.locator(".result-item") - expect(items).to_have_count(20) - - # 2. Test Shift+Click - # Click item 0 (check it) - items.nth(0).click() - expect(items.nth(0).locator("input")).to_be_checked() - - # Shift+Click item 4 (should check 0-4) - items.nth(4).click(modifiers=["Shift"]) - - expect(items.nth(1).locator("input")).to_be_checked() - expect(items.nth(2).locator("input")).to_be_checked() - expect(items.nth(3).locator("input")).to_be_checked() - expect(items.nth(4).locator("input")).to_be_checked() - - print("Shift+Click test passed") - - # 3. Test Touch/Drag Logic - # Reset page state - page.reload() - page.click("button:text('搜索')") - page.wait_for_selector(".result-item") - - # Dispatch TouchStart via JS manually to avoid browser context issues - print("Simulating Touch Start via JS...") - page.evaluate("""(index) => { - const el = document.querySelectorAll('.result-item')[index]; - const touch = new Touch({ - identifier: 0, - target: el, - clientX: 100, - clientY: 100 - }); - const event = new TouchEvent('touchstart', { - touches: [touch], - targetTouches: [touch], - changedTouches: [touch], - bubbles: true, - cancelable: true - }); - el.dispatchEvent(event); - }""", 2) - - # Wait for 600ms (threshold is 500ms) - page.wait_for_timeout(600) - - # Check if class is applied. - # Note: 'drag-selecting' is applied conditionally in Vue template: - # :class="['result-item', (touchState.dragging && ...) ? 'drag-selecting' : '']" - # So if state changed, class should appear. - expect(items.nth(2)).to_have_class(re.compile(r"drag-selecting")) - print("Long press triggered class change") - - # Check that item 2 got selected/toggled - expect(items.nth(2).locator("input")).to_be_checked() - - # Verify Sticky Header - header = page.locator("header") - expect(header).to_have_css("position", "sticky") - expect(header).to_have_css("top", "0px") - print("Sticky header verified") - - # Take screenshot - page.screenshot(path="verification/interaction_test.png") - -if __name__ == "__main__": - with sync_playwright() as p: - # Launch with arguments if necessary, but plain launch usually works if we don't use page.touchscreen APIs directly - browser = p.chromium.launch() - # Create context with has_touch=True if we want to rely on browser capabilities, - # but here we dispatch events manually. - page = browser.new_page() - try: - test_interaction(page) - except Exception as e: - print(f"Test failed: {e}") - # Take screenshot on failure - try: - page.screenshot(path="verification/failure.png") - except: - pass - finally: - browser.close() diff --git a/verification/verify_spa.py b/verification/verify_spa.py deleted file mode 100644 index 6f44f6b..0000000 --- a/verification/verify_spa.py +++ /dev/null @@ -1,158 +0,0 @@ - -from playwright.sync_api import sync_playwright, expect -import json -import time - -def run(playwright): - browser = playwright.chromium.launch(headless=True) - page = browser.new_page() - - # Mock Search Results - mock_courses = [ - { - "name": "Course A", - "code": "1001", - "teacher": "Teacher A", - "location_text": "周一 1-2 (仙II-101)", - "schedule_bitmaps": ["0", "3", "3", "3"], # Week 1-3, Day 1 Node 1-2 (Bits 0,1) -> 3 - "sessions": [{"weeks": [1,2,3], "day": 0, "start": 1, "end": 2, "location": "仙II-101"}], - "credit": 2, - "hours": 32 - }, - { - "name": "Course B", - "code": "1002", - "teacher": "Teacher B", - "location_text": "周二 9-10 (仙II-102)", - "schedule_bitmaps": ["0", "786432", "786432", "786432"], # Week 1-3, Day 2 Node 9-10. Bits: 13+8=21, 13+9=22. 2^21 | 2^22. - "sessions": [{"weeks": [1,2,3], "day": 1, "start": 9, "end": 10, "location": "仙II-102"}], - "credit": 2, - "hours": 32 - } - ] - - # Bits calculation for Course B: - # Day 2 is index 1. - # Node 9 is index 8. 1*13 + 8 = 21. - # Node 10 is index 9. 1*13 + 9 = 22. - # 2^21 = 2097152 - # 2^22 = 4194304 - # Sum = 6291456 - # Wait, my mock above "786432" seems wrong calculation. - # Let's use JS to calculate in runtime or just rely on sessions if solver uses sessions. - # Solver uses sessions for stats, but uses bitmaps for conflict/ranking. - # I should provide correct bitmaps. - # 2^21 + 2^22 = 6291456. - - mock_courses[1]["schedule_bitmaps"] = ["0", "6291456", "6291456", "6291456"] - - - # Mock /search endpoint - page.route("**/search*", lambda route: route.fulfill( - status=200, - content_type="application/json", - body=json.dumps(mock_courses) - )) - - # 1. Load Page - page.goto("http://localhost:8000") - expect(page.locator("h1")).to_contain_text("南哪选课助手") - - # 2. Search - page.locator("input[placeholder*='课程名']").fill("Course") - page.get_by_text("搜索", exact=True).click() - - # Wait for results - expect(page.get_by_text("搜索结果 (2)")).to_be_visible() - - # 3. Create Group - # Select all - page.get_by_text("全选/反选").click() - page.get_by_text("将选中项存为一组").click() - expect(page.get_by_text("已添加新课程组")).to_be_visible() - - # 4. Switch to Planning - page.get_by_text("2. 规划 & 策略").click() - - # Check new "Skippable" checkbox existence - skippable_checkbox = page.locator(".group-header input[type='checkbox']") - expect(skippable_checkbox).to_be_visible() - - # Check new "Quality Sleep" preference - sleep_pref = page.get_by_text("优质睡眠 (9-13节)") - expect(sleep_pref).to_be_visible() - - # 5. Enable Skippable for Group 1 (Course A & B are in one group because we selected both and clicked create once) - # Wait, "createGroup" creates ONE group with all selected candidates. - # So Group 1 contains Course A and Course B as candidates for the SAME slot? - # No, typically user selects one course's candidates. - # But if I select Course A and Course B, they become candidates for Group 1. - # This means I can choose A OR B. - # Let's say I want both. I should have added them separately. - # But for this test, let's assume they are alternatives. - # Solver will pick ONE. - - # Let's delete this group and add them separately for better visual. - page.get_by_text("删除").click() - - # Go back to Search - page.get_by_text("1. 课程查询").click() - - # Select only Course A - page.get_by_text("全选/反选").click() # Unselect all - - # Find checkbox for Course A. - # result-item - rows = page.locator(".result-item") - rows.nth(0).locator("input[type='checkbox']").check() - page.get_by_text("将选中项存为一组").click() - - # Uncheck A, Check B - rows.nth(0).locator("input[type='checkbox']").uncheck() - rows.nth(1).locator("input[type='checkbox']").check() - page.get_by_text("将选中项存为一组").click() - - # Go to Planning - page.get_by_text("2. 规划 & 策略").click() - expect(page.get_by_text("我的课程组 (2)")).to_be_visible() - - # 6. Mark Course B (Group 2) as Skippable - # Group 2 is the second one. - # Locator for group items - groups = page.locator(".group-item") - group2_skippable = groups.nth(1).locator(".group-header input[type='checkbox']") - group2_skippable.check() - - # 7. Generate Schedule - page.get_by_text("生成课表方案").click() - - # 8. Check Results - # Should automatically switch to Results view - expect(page.get_by_text("推荐方案")).to_be_visible() - - # Check Stats for "Actual Weekly Hours" - # Course A: 2 credits, 32 hours (approx 2/week if 16 weeks, here 3 weeks -> ~10/week? mock data says 32 total) - # Course B: 2 credits, 32 hours. Skippable. - # Stats logic: actual = total - skippable. - # Total hours: 64. Actual: 32. - # Week span: 1-3. (3 weeks). - # Actual Avg Weekly: 32 / 3 = 10.7 - - stats_section = page.locator("#capture-area") - expect(stats_section).to_contain_text("实际每周学时") - - # Check Green Cell - # Course B is skippable. It is on Tue 9-10. - # Tue is column index 3 (Node, Mon, Tue...). - # Row 9-10. - # Let's just look for the class `.cell-active.skippable` - # Course B spans 2 periods, so we expect at least 1 visible, or strict mode fails if multiple. - skippable_cell = page.locator(".cell-active.skippable").first - expect(skippable_cell).to_be_visible() - expect(skippable_cell).to_contain_text("Course B") - - # Screenshot - page.screenshot(path="verification/spa_verified.png") - -with sync_playwright() as playwright: - run(playwright)