diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd40a..a8690ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Added + +- Added normalized lecture/lab selection bundles with explicit component, + credit-bearing, mutation-ID, task-RWH, and read-back contracts. +- Added bounded `tis selection reconcile` reads for uncertain enrollment, + cart, drop, and bid outcomes. + +### Changed + +- Made planning-oriented availability, enrollment, degree-progress, and + degree-missing JSON use documented minimum-data projections; grade-free + output is the default. + +### Fixed + +- Made `auth status` use a metadata-only macOS Keychain lookup instead of + reading the stored password, and bounded credential-helper subprocesses to + five seconds with a structured `CREDENTIAL_STORE_TIMEOUT` status. +- Linux Secret Service writes now require an immediate verified read-back and + report actionable locked-collection, D-Bus-session, and access-denied states. + +### Security + +- Selection transport ambiguity now returns an explicit non-retriable outcome + with a local correlation ID, while raw upstream mutation and personal + selection envelopes are excluded from default CLI output. + ## [0.10.0] - 2026-08-29 ### Added diff --git a/README.md b/README.md index 89698a6..f88c131 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,14 @@ in the operating system's native credential store: The password is entered through a hidden prompt, is never accepted as a normal command-line argument, and is never written to the CLI config. If no safe backend is available, the CLI returns `CREDENTIAL_STORE_UNAVAILABLE` instead of -falling back to plaintext. +falling back to plaintext. Linux writes are verified by immediate read-back; +locked collections and broken desktop D-Bus sessions produce distinct safe +remediation in `auth status --json` instead of being reported as an expired +password. + +On macOS, `auth status` checks Keychain item metadata without reading the +password. Credential-helper commands are bounded to five seconds and report +`CREDENTIAL_STORE_TIMEOUT` without an automatic retry. ```bash sustech auth login --profile main @@ -257,6 +264,19 @@ sustech tis enroll apply \ --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --bid 2 --confirm ``` +Availability JSON groups lecture/lab rows into credit-deduplicated bundles and +labels the exact `courseId` (`p_id`) and component `rwh` roles. If apply returns +`TIS_SELECTION_OUTCOME_UNKNOWN`, preserve that exact pair and reconcile without +repeating the write: + +```bash +sustech tis selection reconcile enroll \ + --course-id TIS_INTERNAL_ID --rwh TASK_ID --round bxxk --attempts 3 --json +``` + +See [docs/SELECTION_CONTRACTS.md](docs/SELECTION_CONTRACTS.md) for bundle, +identifier, bounded reconciliation, and grade-free planning-output contracts. + Blackboard attachment and submission example: ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53b2e3c..d03d4f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,6 +145,11 @@ commands for them. - Mutation commands use explicit preview/build phases and post-action verification. Any ambiguous remote result returns exit code 5 plus `DO_NOT_RETRY_AUTOMATICALLY` when write state cannot be determined safely. +- TIS selection previews carry a local correlation ID but never claim upstream + idempotency. Transport ambiguity is reconciled through bounded exact + `{courseId, rwh, round}` reads rather than by repeating a mutation. +- Planning-facing TIS output passes through field-allowlisted projections; + broad upstream rows and raw mutation responses remain outside CLI JSON. - Consequence metadata lives in `src/core/consequences.ts` so agents can inspect risks and follow-up checks without scraping prose. - New authenticated campus-service wrappers are validated with protocol fixtures diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c8a94fa..e0835bc 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -68,6 +68,20 @@ Linux deliberately requires a desktop D-Bus session and the distribution's `secret-tool`/`libsecret-tools` package. It does not silently fall back to a plaintext file or a session-only kernel keyring. +`auth status` does not read the stored password when checking macOS Keychain. +It uses a metadata-only `security find-generic-password` lookup without `-w`. +Credential-helper subprocesses have a five-second deadline and are never +retried automatically. If a helper exceeds that deadline, structured status +sets `reasonCode` to `CREDENTIAL_STORE_TIMEOUT`, marks the backend unavailable +for that probe, and leaves the credential and profile metadata unchanged. + +Credential writes are verified by an immediate read-back before profile +metadata is committed. Linux errors distinguish a locked collection, a missing +desktop D-Bus/Secret Service session, an access denial, and an unclassified +`secret-tool` failure. Run `sustech auth status --json` in the same unlocked +graphical session and follow its `remediation`; do not delete profile metadata +or assume the password expired merely because the collection is locked. + ## Profiles The default profile is named `default`. Multiple accounts use explicit names: diff --git a/docs/OUTPUT.md b/docs/OUTPUT.md index dfade5c..8333243 100644 --- a/docs/OUTPUT.md +++ b/docs/OUTPUT.md @@ -72,6 +72,12 @@ Credential commands return backend, profile, availability, and masked account metadata only. Passwords, cookies, bearer tokens, and keyring values are never part of text, JSON, JSONL, error details, or capability output. +Planning-oriented TIS commands additionally use field allowlists. Available +courses are emitted as normalized bundles, enrolled rows omit broad description +fields, and `tis degree missing` omits letter grades and numeric scores. Course +grades in `tis degree progress` require the explicit `--details` option. See +`SELECTION_CONTRACTS.md` for exact bundle, identifier, and projection semantics. + Blackboard attachment listings likewise omit signed `bbcswebdav` URLs. A successful `bb download` result contains only stable attachment metadata, the absolute destination path, byte count, content type, and SHA-256. diff --git a/docs/SELECTION_CONTRACTS.md b/docs/SELECTION_CONTRACTS.md new file mode 100644 index 0000000..4d0dc8b --- /dev/null +++ b/docs/SELECTION_CONTRACTS.md @@ -0,0 +1,60 @@ +# TIS selection contracts + +The selection surface separates catalog rows, selectable bundles, mutation identifiers, and read-back identifiers. Consumers must not infer one identifier's meaning from its spelling. + +## Bundled availability + +`sustech tis courses available ... --json` returns `data.bundles`. A bundle contains: + +- `bundleId`: an explicit source bundle ID when TIS exposes one; otherwise a stable selection/task-scoped identity. +- `components`: lecture, lab, tutorial, other, or unknown rows. Every component states whether it is required and identifies its task `rwh`. +- `credits` and `creditStatus`: equal repeated component credits are counted once. Conflicting component credits produce `creditStatus: "ambiguous"` and omit `credits` instead of guessing or summing. +- `teachingTeam` and `meetings`: unions across all components, retaining parity-week schedules. +- `operationTargets`: exact component-level mutation `courseId`, task `rwh`, payload field, and read-back identity. +- `selectableWithoutGuessing`: true only when every required component has the explicit identifier pair needed for mutation and verification, and source course identity and credit evidence do not conflict. + +Duplicate source rows for the same component are merged and reported in `warnings`. Default CLI output never contains the upstream selection envelope, enrolled/cart raw rows, credentials, cookies, tokens, or unrelated student fields. `retainCourseSourceRecord` is a library-level diagnostics-only escape hatch and is not called by CLI commands. + +## Identifier meanings + +| Name | Meaning | Accepted by | +| --- | --- | --- | +| `bundleId` | normalized course bundle identity | display, planning, grouping only | +| `componentId` / `taskId` / `rwh` | exact teaching-task component | required together with `courseId` for apply and reconciliation read-back | +| `courseId` | opaque selection mutation identifier | CLI `--course-id`; serialized as upstream `p_id` | +| `clientRequestId` | local correlation identifier | output/errors only; it is not an upstream idempotency key | + +Do not pass `bundleId`, course code, or `rwh` as `--course-id`. Do not treat `courseId` alone as a unique lecture/lab component: exact verification keys on `{courseId, rwh}`. + +## Uncertain writes and reconciliation + +TIS does not currently expose a verified idempotency-key facility for these endpoints. Every preview therefore says `upstreamKeySupported: false` and `automaticRetry: "forbidden"`. The generated `clientRequestId` is not added to the upstream payload. + +If a request is known to fail before submission, the CLI returns `TIS_SELECTION_NOT_SUBMITTED`, exit 4, and `NO_MUTATION_PERFORMED`. If submission may have started but no conclusive response arrives, it returns `TIS_SELECTION_OUTCOME_UNKNOWN`, exit 5, and `DO_NOT_RETRY_AUTOMATICALLY`. + +Use the exact target from the error: + +```bash +sustech tis selection reconcile cart.add \ + --course-id SELECTION_ID --rwh TASK_ID --round bxxk \ + --attempts 3 --json +``` + +Reconciliation performs two to five bounded read-only queries and reports: + +- `applied`: the final bounded observation reached the requested exact state; +- `not_applied`: at least two consistent exact observations retained the inverse state and no desired/conflicting observation appeared; +- `still_uncertain`: a query failed, identifiers conflicted, round metadata was missing or mismatched, observations regressed, or evidence remained incomplete (including a missing bid value). + +None of these states authorizes an automatic mutation retry. `not_applied` means a human or higher-level workflow may review a new preview; it does not reuse the uncertain request. + +## Privacy-minimized planning projections + +Planning commands use documented allowlists: + +- `tis courses available`: normalized bundles, components, exact identifiers, teaching teams, meetings, capacity/credit context, and report time; +- `tis enrolled`: course identity, exact `rwh`, teaching team, and meeting coordinates; +- `tis degree progress`: summary/category/module data by default; course grades appear only with explicit `--details`; +- `tis degree missing`: completion classification may guide gap reasoning, but letter grades and numeric scores are removed from both text and JSON. + +The projection guard rejects credential, cookie, token, raw-envelope, SID, and unrelated student-identifier keys before output. diff --git a/skills/sustech-cli/SKILL.md b/skills/sustech-cli/SKILL.md index aa78dc2..119d8d9 100644 --- a/skills/sustech-cli/SKILL.md +++ b/skills/sustech-cli/SKILL.md @@ -52,8 +52,8 @@ includes these high-value areas: `tis ical`, `tis degree progress`, `tis degree missing`, `tis degree audit`. - TIS writes: `tis selection preview/apply` for `cart`, `drop`, and `bid` - style operations, `tis bid plan`, `tis bid apply`, `tis enroll preview`, - `tis enroll apply`. + style operations, read-only `tis selection reconcile`, `tis bid plan`, `tis + bid apply`, `tis enroll preview`, `tis enroll apply`. - Other authenticated campus services: `ws programs`, `ws detail`, `library search`, `library detail`, `booking whoami`, `booking rooms`, `booking my-meetings`, `booking create preview/apply`, @@ -132,6 +132,14 @@ direct CLI and the approval workflow below when state must change. `meta`, and `schemaVersion` in the output envelope. - Do not parse human-readable text when a JSON mode is available. - Preserve IDs exactly as returned; Blackboard and TIS IDs are opaque strings. +- For `tis courses available`, consume `data.bundles`, count bundle credits + once, include every required component, and use only its documented + `operationTargets`. `courseId` becomes upstream `p_id`; `rwh` identifies the + exact component for read-back. Never guess one from the other. +- Planning output is minimum-data by default. `tis degree missing` is + grade-free, and course grades in `tis degree progress` require an explicit + `--details`; do not request details when summary/category/module evidence is + sufficient. ## Handle credentials @@ -147,6 +155,10 @@ direct CLI and the approval workflow below when state must change. mechanism. Never create a plaintext fallback. - Use `sustech auth status --json` and the appropriate read-only `sustech auth check --service ... --json` before a workflow that needs login. +- On Linux, a locked Secret Service collection or missing desktop D-Bus session + is not evidence that credentials expired. Follow the structured + `remediation`, keep profile metadata intact, and retry only after the same + graphical login collection is unlocked. - Use `--profile` when the task depends on a specific account identity. - If CAS returns `CAS_INTERACTIVE_CHALLENGE_REQUIRED`, report that the password was not submitted and stop. Do not bypass, solve, or repeatedly retry the @@ -201,7 +213,10 @@ Important command-specific rules: - For file-bound applies such as Blackboard submission and PMS upload, preserve the exact previewed SHA-256 into apply. - If a result is ambiguous or contains `DO_NOT_RETRY_AUTOMATICALLY`, stop and - report it; do not retry the mutation automatically. + report it; do not retry the mutation automatically. For a TIS selection + timeout, run bounded `tis selection reconcile OP` with the exact + `courseId`/`rwh`/round from the error. Its `applied`, `not_applied`, or + `still_uncertain` result is read-only and never authorizes an automatic retry. ## Guard local writes and exports diff --git a/src/cli.ts b/src/cli.ts index a1f7c44..98a24bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -128,6 +128,14 @@ import { } from "./tis/course-decision.js"; import { formatCourseRecommendationReport } from "./tis/course-decision-text.js"; import { deriveTisDegreeMissing } from "./tis/degree-missing.js"; +import { + PLANNING_PROJECTION_FIELDS, + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "./tis/planning-projection.js"; import { parseBlockedTime, solveTimetables } from "./tis/planner.js"; import { addPlanEntries, createPlanDocument, loadPlan, removePlanEntries, savePlan } from "./tis/plan.js"; import { @@ -146,6 +154,7 @@ import { parseShenzhenExamTimeRange, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, resolveLiveRoom, scheduleIcsEvents, @@ -442,6 +451,7 @@ Usage: sustech tis degree missing [--semester YYYY-YYYY-N] sustech tis degree audit --requirements FILE [--semester YYYY-YYYY-N] sustech tis selection preview OP --course-id ID [--rwh RWH] [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] + sustech tis selection reconcile OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] [--attempts 2-5] sustech tis selection apply OP --course-id ID --rwh RWH [--semester YYYY-YYYY-N] [--round ROUND] [--bid N] [--where cart|enrolled] [--cultivation 1|2] --confirm sustech tis bid plan --pick COURSE_ID:BID|RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--bid-limit N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] sustech tis bid apply --pick RWH:COURSE_ID:BID [--pick ...] [--semester YYYY-YYYY-N] [--where cart|enrolled] [--round ROUND] [--cultivation 1|2] --confirm @@ -562,6 +572,7 @@ type Values = OutputFlags & { path?: string; requirements?: string; details?: boolean; + attempts?: string; since?: string; until?: string; "url-stdin"?: boolean; @@ -773,13 +784,21 @@ async function main(argv: string[]): Promise { if (limit > 500) throw usageError("--limit cannot exceed 500 for selectable-course queries."); const keyword = parsed.positionals.slice(3).join(" ") || undefined; const result = await client.searchAvailable(semester, { keyword, round, limit }); - const data = { semester, ...result }; + const data = { + semester, + round: projectSelectionRoundForPlanning(result.round), + bundles: result.bundles, + total: result.total, + reportedAt: result.reportedAt, + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.availability }, + }; + assertPlanningProjection(data); writeSuccess({ command: "tis courses available", data, text: formatAvailableCourses({ semester, courses: result.courses, total: result.total, round }), - items: result.courses, - summary: { semester: semester.value, round, total: result.total, shown: result.courses.length }, + items: result.bundles, + summary: { semester: semester.value, round, total: result.total, shown: result.bundles.length }, meta: { enrolledCount: result.enrolled.length, cartCount: result.cart.length }, }, output); return; @@ -788,13 +807,20 @@ async function main(argv: string[]): Promise { const semester = parseSemester(values.semester); const client = await tisClient(values); const courses = await client.enrolled(semester); - const data = { semester, courses, total: courses.length }; + const projectedCourses = projectEnrollmentForPlanning(courses); + const data = { + semester, + courses: projectedCourses, + total: projectedCourses.length, + reportedAt: new Date().toISOString(), + projection: { mode:"planning-minimum", fieldAllowlist:PLANNING_PROJECTION_FIELDS.enrollment }, + }; writeSuccess({ command: "tis enrolled", data, text: formatEnrolledCourses(semester, courses), - items: courses, - summary: { semester: semester.value, total: courses.length }, + items: projectedCourses, + summary: { semester: semester.value, total: projectedCourses.length }, }, output); return; } @@ -1115,8 +1141,7 @@ async function main(argv: string[]): Promise { let calendar: AcademicCalendar | undefined; let term = undefined; let calendarFailure: string | undefined; - const needsCalendar = includes.includes("holidays") - || (includes.includes("schedule") && values["week-one-monday"] === undefined && values["teaching-start"] === undefined); + const needsCalendar = includes.includes("holidays") || includes.includes("schedule"); if (needsCalendar) { try { calendar = await new CalendarClient().loadYear(calendarYearForSemester(semester), level); @@ -1148,14 +1173,28 @@ async function main(argv: string[]): Promise { try { const entries = await tis.schedule(semester); const anchor = await resolveTisIcalAnchor(values, semester, tis, term); - const scheduleEvents = scheduleIcsEvents(entries, anchor); + const scheduleEvents = scheduleIcsEvents(entries, anchor, term); events.push(...scheduleEvents); + const calendarAdjustmentUnavailable = term === undefined; + if (calendarAdjustmentUnavailable) { + omissions.push({ + source: "schedule", + code: "CALENDAR_ADJUSTMENTS_UNAVAILABLE", + message: calendarFailure + ? `Schedule dates were exported without holiday or compensatory-day adjustments: ${calendarFailure}` + : `Schedule dates were exported without holiday or compensatory-day adjustments because ${semester.value} was absent from the academic calendar.`, + }); + } sourceStatuses.schedule = { requested: true, - state: scheduleEvents.length > 0 ? "included" : "omitted", + state: scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? "partial" : "included") + : "omitted", eventCount: scheduleEvents.length, - omissionCount: scheduleEvents.length > 0 ? 0 : 1, - ...(scheduleEvents.length > 0 ? {} : { message: "No scheduled classes were available for the selected semester." }), + omissionCount: scheduleEvents.length > 0 ? (calendarAdjustmentUnavailable ? 1 : 0) : 1, + ...(scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? { message: "Academic-calendar adjustments were unavailable." } : {}) + : { message: "No scheduled classes were available for the selected semester." }), }; if (scheduleEvents.length === 0) { omissions.push({ @@ -1352,6 +1391,7 @@ async function main(argv: string[]): Promise { { operation: selectionOperation, courseId, + ...(rwh ? { rwh } : {}), ...(values.round ? { round: opaqueToken(values.round, "--round") } : {}), bid, where, @@ -1394,6 +1434,54 @@ async function main(argv: string[]): Promise { }, output); return; } + if (command === "selection" && operation === "reconcile" && parsed.positionals.length === 4) { + const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); + const semester = parseSemester(values.semester); + const cultivation = selectionCultivation(values.cultivation); + const target = selectionApplyTarget(values, selectionOperation); + const attempts = parsePositiveInteger(values.attempts, 3, "--attempts"); + if (attempts < 2 || attempts > 5) throw usageError("--attempts must be between 2 and 5."); + const client = await tisClient(values); + const states: TisSelectionState[] = []; + const readErrors: string[] = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + states.push(await client.selectionState(semester, { + keyword: "", + round: target.round, + limit: 500, + cultivation, + })); + } catch (error) { + readErrors.push(error instanceof CliError ? error.code : "UNKNOWN_READ_ERROR"); + } + if (attempt < attempts) await boundedWait(750); + } + const base = reconcileSelectionSnapshots(states, target); + const reconciliation = readErrors.length > 0 + ? { + ...base, + status: "still_uncertain" as const, + readErrors, + message: "At least one bounded read-back failed; the outcome remains uncertain and must not be retried automatically.", + } + : base; + writeSuccess({ + command: "tis selection reconcile", + data: { semester, cultivation, reconciliation }, + text: [ + `Reconciliation: ${reconciliation.status}`, + `Exact target: ${target.courseId} / ${target.rwh} / ${target.round}`, + `Read-back attempts: ${attempts}`, + reconciliation.message, + "Automatic retry: forbidden", + ].join("\n"), + items: reconciliation.observations, + summary: { status: reconciliation.status, attempts, readErrors: readErrors.length }, + meta: { warning: "DO_NOT_RETRY_AUTOMATICALLY" }, + }, output); + return; + } if (command === "selection" && operation === "apply" && parsed.positionals.length === 4) { const selectionOperation = selectionOperationValue(required(parsed.positionals[3], "selection operation")); if (selectionOperation === "enroll") { @@ -1431,6 +1519,7 @@ async function main(argv: string[]): Promise { { operation: target.operation, courseId: target.courseId, + rwh: target.rwh, round: target.round, bid: target.bid, where: target.where, @@ -1441,6 +1530,7 @@ async function main(argv: string[]): Promise { throw new CliError(result.message || "TIS rejected the selection mutation.", "TIS_WRITE_REJECTED", 4, { target, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification; @@ -1595,6 +1685,7 @@ async function main(argv: string[]): Promise { { operation: "bid.update", courseId: pick.courseId, + rwh: pick.rwh!, round, bid: pick.bid, where, @@ -1608,6 +1699,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, result, warning: "DO_NOT_RETRY_AUTOMATICALLY", }); @@ -1617,6 +1709,7 @@ async function main(argv: string[]): Promise { confirmed, unchanged, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } try { @@ -1711,11 +1804,12 @@ async function main(argv: string[]): Promise { if (command === "degree" && operation === "progress" && parsed.positionals.length === 3) { const client = await tisClient(values); const progress = await client.degreeProgress({ details: values.details === true }); + const projectedProgress = projectDegreeProgressForPlanning(progress, { includeGrades: values.details === true }); writeSuccess({ command: "tis degree progress", - data: progress, + data: projectedProgress, text: formatDegreeProgress(progress), - ...(progress.detailsIncluded && progress.courses ? { items: progress.courses } : {}), + ...(projectedProgress.detailsIncluded && projectedProgress.courses ? { items: projectedProgress.courses } : {}), summary: { dataAvailable: progress.dataAvailable, detailsRequested: progress.detailsRequested, @@ -1736,10 +1830,11 @@ async function main(argv: string[]): Promise { const semester = values.semester ? parseSemester(values.semester) : undefined; const client = await tisClient(values); const report = await deriveTisDegreeMissing(client, { semester }); + const projectedReport = projectDegreeMissingForPlanning(report); writeSuccess({ command: "tis degree missing", - data: report, - text: formatDegreeMissing(report), + data: projectedReport, + text: formatDegreeMissing(projectedReport), summary: { definiteMissingRequiredCourses: report.counts.definiteMissingRequiredCourses, inProgressRequiredCourses: report.counts.inProgressRequiredCourses, @@ -1784,6 +1879,7 @@ async function main(argv: string[]): Promise { action: "enroll", rwh: target.rwh, tisCode: result.jg, + clientRequestId: result.clientRequestId, }); } let verification: { status: "confirmed" | "not_observed" | "unavailable"; message: string }; @@ -2978,7 +3074,10 @@ async function loadLiveContext( if (scheduleResult.status === "fulfilled") { if (currentWeek > 0) { - const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now }); + const calendarTerm = calendar.terms().find((candidate) => ( + candidate.snapshot.semester.value === semester.value + )); + const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now, calendarTerm }); result.schedule = schedule; liveSources.tisSchedule = { state: schedule.now || schedule.next || schedule.tomorrowMorning ? "provided" : "missing", @@ -5890,6 +5989,10 @@ function defaultSelectionBid(operation: SelectionOperation): number { return operation === "drop" || operation === "cart.remove" ? 1 : 1; } +function boundedWait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function buildEnrollApplyCommand(target: { semester: ReturnType; courseId: string; diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index deb89d9..473182d 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -125,6 +125,7 @@ export const CAPABILITIES: readonly Capability[] = [ capability("tis degree audit", "Audit live grade records against a local JSON requirement file without auto-resolving ambiguous matches.", "plan", { authentication: "tis", status: "preview" }), capability("tis enroll preview", "Build an exact enrollment action without network or mutation.", "plan", { network: false, status: "preview" }), capability("tis selection preview", "Build typed enroll, drop, cart, or bid payloads without sending them, with optional exact RWH handoff for apply.", "plan", { network: false, status: "preview" }), + capability("tis selection reconcile", "Poll an exact courseId/RWH/round target after an uncertain selection write without repeating the mutation.", "read", { authentication: "tis", status: "preview" }), capability("tis selection apply", "Submit an exact cart, drop, or bid mutation to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), capability("tis bid plan", "Validate a multi-course bid budget and build local write previews.", "plan", { network: false, status: "preview" }), capability("tis bid apply", "Submit exact multi-course bid updates to TIS after live revalidation.", "mutation", { authentication: "tis", confirmation: "required", status: "preview" }), diff --git a/src/core/command-metadata.ts b/src/core/command-metadata.ts index 1f7f931..0e2a719 100644 --- a/src/core/command-metadata.ts +++ b/src/core/command-metadata.ts @@ -91,6 +91,7 @@ export const CLI_PARSE_OPTIONS = { path: { type: "string" }, requirements: { type: "string" }, details: { type: "boolean", default: false }, + attempts: { type: "string" }, since: { type: "string" }, until: { type: "string" }, "url-stdin": { type: "boolean", default: false }, @@ -239,6 +240,7 @@ export const COMMAND_OPTIONS: Readonly> "tis degree missing": ["credentials-file", "semester"], "tis enroll preview": ["semester", "course-id", "rwh", "round", "bid"], "tis selection preview": ["semester", "course-id", "rwh", "round", "bid", "where", "cultivation"], + "tis selection reconcile": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "attempts"], "tis selection apply": ["credentials-file", "semester", "course-id", "rwh", "round", "bid", "where", "cultivation", "confirm"], "tis bid plan": ["semester", "pick", "bid-limit", "where", "round", "cultivation"], "tis bid apply": ["credentials-file", "semester", "pick", "where", "round", "cultivation", "confirm"], diff --git a/src/core/keyring.ts b/src/core/keyring.ts index d7f43cd..25a5e17 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -9,6 +9,7 @@ import { defaultConfigDirectory } from "./local-store.js"; export const DEFAULT_CREDENTIAL_PROFILE = "default"; export const SUSTECH_CREDENTIAL_SERVICE = "cn.edu.sustech.cli.cas"; export const BLACKBOARD_CALENDAR_LINK_SERVICE = "cn.edu.sustech.cli.bb-calendar-link"; +export const DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS = 5_000; export type CredentialBackend = | "macos-keychain" @@ -18,6 +19,7 @@ export type CredentialBackend = export interface SecretStore { readonly backend: CredentialBackend; readonly persistent: true; + has?(account: string): Promise; get(account: string): Promise; set(account: string, password: string): Promise; delete(account: string): Promise; @@ -28,6 +30,7 @@ export interface CredentialStoreOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; store?: SecretStore; + credentialCommandTimeoutMs?: number; } interface StoredProfile { @@ -60,6 +63,7 @@ export interface CredentialProfileStatus { persistent: boolean; storedAt?: string; profiles: string[]; + reasonCode?: "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT"; reason?: string; remediation?: string; } @@ -157,10 +161,25 @@ export async function saveStoredCredentials( const account = existing?.account ?? credentialAccount(profile, sid); let previousPassword: string | undefined; + let secretTouched = false; try { previousPassword = await store.get(account); await store.set(account, password); + secretTouched = true; + const verifiedPassword = await store.get(account); + if (verifiedPassword !== password) { + throw credentialStoreDiagnostic( + "Credential store write could not be verified by an immediate read-back.", + "CREDENTIAL_WRITE_NOT_VERIFIED", + store.backend === "linux-secret-service" + ? "Unlock the desktop login keyring, then run auth login again in the same graphical session." + : "Unlock the operating-system credential store, then run auth login again.", + ); + } } catch (error) { + if (secretTouched && !await restoreSecret(store, account, previousPassword)) { + throw credentialRollbackError("save", store.backend); + } throw storeAccessError("credentials", "write", store.backend, error); } @@ -270,18 +289,20 @@ export async function getCredentialStatus( } try { - const password = await resolution.store.get(stored.account); + const credentialAvailable = resolution.store.has + ? await resolution.store.has(stored.account) + : Boolean(await resolution.store.get(stored.account)); return { profile, configured: true, - credentialAvailable: Boolean(password), + credentialAvailable, maskedSid: maskSid(stored.sid), backend: stored.backend, backendAvailable: true, persistent: true, storedAt: stored.storedAt, profiles, - ...(!password ? { reason: "The profile metadata exists, but the secret is missing from the credential store." } : {}), + ...(!credentialAvailable ? { reason: "The profile metadata exists, but the secret is missing from the credential store." } : {}), }; } catch (error) { return { @@ -294,7 +315,9 @@ export async function getCredentialStatus( persistent: true, storedAt: stored.storedAt, profiles, + reasonCode: credentialStatusReasonCode(error), reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), }; } } @@ -433,9 +456,9 @@ async function resolveBackendForNamespace( }; } const platform = options.platform ?? process.platform; - if (platform === "darwin") return await resolveMacosKeychain(options.env, namespace); + if (platform === "darwin") return await resolveMacosKeychain(options, namespace); if (platform === "win32") return await resolveWindowsCredentialManager(namespace); - if (platform === "linux") return await resolveLinuxSecretService(options.env, namespace); + if (platform === "linux") return await resolveLinuxSecretService(options, namespace); return { backend: "unavailable", available: false, @@ -446,12 +469,13 @@ async function resolveBackendForNamespace( } async function resolveMacosKeychain( - customEnv: NodeJS.ProcessEnv | undefined, + options: CredentialStoreOptions, namespace: SecretNamespace, ): Promise { const backend = "macos-keychain" as const; const executable = "/usr/bin/security"; - const env = customEnv ?? process.env; + const env = options.env ?? process.env; + const timeoutMs = credentialCommandTimeoutMs(options); try { await access(executable, constants.X_OK); const { AsyncEntry } = await import("@napi-rs/keyring"); @@ -460,10 +484,13 @@ async function resolveMacosKeychain( const store: SecretStore = { backend, persistent: true, + async has(account) { + return await macosCredentialExists(executable, namespace.service, account, env, timeoutMs); + }, async get(account) { const password = await new AsyncEntry(namespace.service, account).getPassword() ?? undefined; if (password !== undefined) return password; - if (!await macosCredentialExists(executable, namespace.service, account, env)) return undefined; + if (!await macosCredentialExists(executable, namespace.service, account, env, timeoutMs)) return undefined; throw new Error("macOS Keychain item exists, but its secret could not be read."); }, async set(account, password) { @@ -471,7 +498,7 @@ async function resolveMacosKeychain( }, async delete(account) { const deleted = await new AsyncEntry(namespace.service, account).deleteCredential(); - if (await macosCredentialExists(executable, namespace.service, account, env)) { + if (await macosCredentialExists(executable, namespace.service, account, env, timeoutMs)) { throw new Error("macOS Keychain delete could not be verified."); } return deleted; @@ -494,10 +521,11 @@ async function macosCredentialExists( service: string, account: string, env: NodeJS.ProcessEnv, + timeoutMs: number, ): Promise { const result = await runCredentialCommand(executable, [ "find-generic-password", "-s", service, "-a", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (macosItemNotFound(result)) return false; if (result.code !== 0) throw new Error("macOS Keychain metadata lookup failed."); return true; @@ -542,10 +570,11 @@ async function resolveWindowsCredentialManager(namespace: SecretNamespace): Prom } async function resolveLinuxSecretService( - customEnv: NodeJS.ProcessEnv | undefined, + options: CredentialStoreOptions, namespace: SecretNamespace, ): Promise { - const env = customEnv ?? process.env; + const env = options.env ?? process.env; + const timeoutMs = credentialCommandTimeoutMs(options); if (!env.DBUS_SESSION_BUS_ADDRESS) { return { backend: "linux-secret-service", @@ -571,9 +600,9 @@ async function resolveLinuxSecretService( async get(account) { const result = await runCredentialCommand(executable, [ "lookup", "service", namespace.service, "account", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (result.code === 1 && !result.stdout.trim() && !result.stderr.trim()) return undefined; - if (result.code !== 0) throw new Error("Secret Service lookup failed."); + if (result.code !== 0) throw secretServiceCommandError("lookup", result); return result.stdout.replace(/\r?\n$/, "") || undefined; }, async set(account, password) { @@ -582,15 +611,15 @@ async function resolveLinuxSecretService( `--label=${namespace.linuxLabel} (${account.split(":", 1)[0]})`, "service", namespace.service, "account", account, - ], `${password}\n`, env); - if (result.code !== 0) throw new Error("Secret Service write failed."); + ], `${password}\n`, env, timeoutMs); + if (result.code !== 0) throw secretServiceCommandError("write", result); }, async delete(account) { const result = await runCredentialCommand(executable, [ "clear", "service", namespace.service, "account", account, - ], undefined, env); + ], undefined, env, timeoutMs); if (result.code === 1 && !result.stderr.trim()) return false; - if (result.code !== 0) throw new Error("Secret Service delete failed."); + if (result.code !== 0) throw secretServiceCommandError("delete", result); return true; }, }; @@ -620,12 +649,26 @@ async function runCredentialCommand( args: string[], input: string | undefined, env: NodeJS.ProcessEnv, + timeoutMs = DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS, ): Promise<{ code: number; stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { const child = spawn(executable, args, { env, stdio: ["pipe", "pipe", "pipe"] }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let size = 0; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new CredentialCommandTimeoutError(timeoutMs)); + }, timeoutMs); + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; child.stdout.on("data", (chunk: Buffer) => { size += chunk.length; if (size <= 64 * 1024) stdout.push(chunk); @@ -634,17 +677,39 @@ async function runCredentialCommand( size += chunk.length; if (size <= 64 * 1024) stderr.push(chunk); }); - child.once("error", reject); - child.once("close", (code) => resolve({ - code: code ?? 1, - stdout: Buffer.concat(stdout).toString("utf8"), - stderr: Buffer.concat(stderr).toString("utf8"), - })); + child.once("error", rejectOnce); + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + code: code ?? 1, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); if (input !== undefined) child.stdin.end(input, "utf8"); else child.stdin.end(); }); } +class CredentialCommandTimeoutError extends Error { + public readonly code = "CREDENTIAL_STORE_TIMEOUT"; + + public constructor(timeoutMs: number) { + super(`Credential-store command exceeded its ${timeoutMs} ms deadline.`); + this.name = "CredentialCommandTimeoutError"; + } +} + +function credentialCommandTimeoutMs(options: CredentialStoreOptions): number { + const timeoutMs = options.credentialCommandTimeoutMs ?? DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) { + throw new Error("Credential-store command timeout must be an integer from 1 to 60000 milliseconds."); + } + return timeoutMs; +} + function requireAvailableStore(resolution: BackendResolution): SecretStore { if (resolution.store) return resolution.store; throw new CliError( @@ -677,10 +742,52 @@ function storeAccessError(subject: string, operation: string, backend: Credentia `Could not ${operation} ${subject} using ${backend}.`, "CREDENTIAL_STORE_ERROR", 2, - { backend, operation, reason: safeStoreReason(error) }, + { + backend, + operation, + reason: safeStoreReason(error), + ...(storeRemediation(error) ? { remediation: storeRemediation(error) } : {}), + }, + ); +} + +function secretServiceCommandError( + operation: "lookup" | "write" | "delete", + result: { code: number; stderr: string }, +): Error { + const stderr = result.stderr.slice(0, 4096); + if (/locked|is locked|collection.*lock/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service collection is locked.", + "SECRET_SERVICE_LOCKED", + "Unlock the desktop login keyring and retry in the same graphical session; do not delete the profile metadata.", + ); + } + if (/D-Bus|dbus|cannot autolaunch|serviceunknown|connection (?:refused|closed)|no such file/i.test(stderr)) { + return credentialStoreDiagnostic( + "The desktop Secret Service session is unavailable.", + "SECRET_SERVICE_SESSION_UNAVAILABLE", + "Run the command inside the same unlocked desktop session that owns DBUS_SESSION_BUS_ADDRESS, or inject process-scoped credentials from an external secret manager.", + ); + } + if (/denied|permission|dismissed|cancelled/i.test(stderr)) { + return credentialStoreDiagnostic( + "The Secret Service request was denied.", + "SECRET_SERVICE_ACCESS_DENIED", + "Allow the keyring prompt and verify that the login collection is unlocked before retrying.", + ); + } + return credentialStoreDiagnostic( + `Secret Service ${operation} failed with exit code ${result.code}.`, + "SECRET_SERVICE_COMMAND_FAILED", + "Run `sustech auth status --json` in the desktop session and verify secret-tool can access the unlocked login collection.", ); } +function credentialStoreDiagnostic(message: string, code: string, remediation: string): Error { + return Object.assign(new Error(message), { code, remediation }); +} + function validateStoredSecret(value: string, subject: string, code: string): string { if (!value || /[\r\n]/.test(value) || Buffer.byteLength(value, "utf8") > 16 * 1024) { throw new CliError( @@ -725,12 +832,27 @@ function credentialRollbackError(operation: "save" | "delete", backend: Credenti } function safeStoreReason(error: unknown): string { + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { + if (/^(?:The Secret Service|Secret Service|Credential store write)/.test(error.message)) return error.message; + } if (error && typeof error === "object" && "code" in error) return `Credential store error ${String(error.code)}.`; return error instanceof Error && /^Secret Service /.test(error.message) ? error.message : "The operating-system credential store rejected or could not complete the request."; } +function credentialStatusReasonCode(error: unknown): "CREDENTIAL_STORE_ERROR" | "CREDENTIAL_STORE_TIMEOUT" { + return error && typeof error === "object" && "code" in error && error.code === "CREDENTIAL_STORE_TIMEOUT" + ? "CREDENTIAL_STORE_TIMEOUT" + : "CREDENTIAL_STORE_ERROR"; +} + +function storeRemediation(error: unknown): string | undefined { + return error && typeof error === "object" && "remediation" in error && typeof error.remediation === "string" + ? error.remediation + : undefined; +} + async function readCredentialConfig(options: CredentialStoreOptions): Promise { const path = credentialConfigPath(options); let raw: string; diff --git a/src/core/text.ts b/src/core/text.ts index 1901f30..18777f5 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -66,10 +66,12 @@ export function formatEnrolledCourses(semester: Semester, courses: PersonalSched const header = `Enrolled courses · ${semester.value}`; if (courses.length === 0) return `${header}\n\nNo enrolled courses returned by TIS.`; const blocks = courses.map((course, index) => { - const name = course.courseName || course.description || course.descriptionEn || "Unnamed course"; + const name = course.courseName || course.rwh || "Unnamed course"; const details = [ course.teacher && `Teacher: ${course.teacher}`, - course.description && `Time: ${course.description}`, + course.day !== undefined && course.periodStart !== undefined + ? `Day ${course.day}, period ${course.periodStart}-${course.periodEnd ?? course.periodStart}` + : "", course.room && `Room: ${course.room}`, ].filter(Boolean).join(" · "); return `${index + 1}. ${course.courseCode ? `${course.courseCode} — ` : ""}${name}${details ? `\n ${details}` : ""}`; diff --git a/src/test/academics.test.ts b/src/test/academics.test.ts index c016d6d..6027dce 100644 --- a/src/test/academics.test.ts +++ b/src/test/academics.test.ts @@ -52,7 +52,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { ZC: "011010", }); assert.equal(schedule.day, 2); - assert.deepEqual(schedule.weeks, [2, 3, 5]); + assert.deepEqual(schedule.weeks, [1, 2, 4]); const fallback = normalisePersonalScheduleEntry({ KEY: "xq5_jc9", @@ -63,6 +63,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { assert.equal(fallback.courseName, "写作课"); assert.equal(fallback.periodStart, 9); assert.equal(fallback.periodEnd, 9); + assert.deepEqual(fallback.weeks, []); }); test("GPA summaries use credit-weighted SUSTech grade points", () => { diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 6b92f4b..bfddf84 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -904,6 +904,24 @@ test("selection and bid apply require --confirm before any credential lookup or assert.equal(JSON.parse(bid.stdout).error.code, "CONFIRMATION_REQUIRED"); }); +test("selection reconciliation is bounded, read-only, and validates locally before credentials", () => { + const invalid = runWithoutCredentials([ + "tis", "selection", "reconcile", "cart.add", + "--course-id", "selection-id", + "--rwh", "task-id", + "--round", "bxxk", + "--attempts", "1", + "--json", + ]); + assert.equal(invalid.status, 2); + assert.equal(JSON.parse(invalid.stdout).error.code, "USAGE"); + + const capabilities = JSON.parse(run(["capabilities", "--json"]).stdout).data.capabilities; + const reconcile = capabilities.find((entry: { command:string }) => entry.command === "tis selection reconcile"); + assert.equal(reconcile.kind, "read"); + assert.equal(reconcile.confirmation, "none"); +}); + test("context live supports calendar level and degrades gracefully when credentials are unavailable", () => { const result = runWithoutCredentials(["context", "--calendar-level", "graduate", "--live", "--json"]); assert.equal(result.status, 0); diff --git a/src/test/client.test.ts b/src/test/client.test.ts index c839ea5..d4eddc3 100644 --- a/src/test/client.test.ts +++ b/src/test/client.test.ts @@ -3,9 +3,11 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { CliError } from "../core/errors.js"; import type { Semester } from "../core/semester.js"; import type { TisSession } from "../tis/auth.js"; import { TisClient } from "../tis/client.js"; +import { buildSelectionPreview } from "../tis/remaining-selection.js"; const SEMESTER: Semester = { xn: "2025-2026", xq: "1", value: "2025-2026-1" }; @@ -60,6 +62,49 @@ test("available-course parsing preserves nested round metadata", async () => { assert.deepEqual(result.round, { xkfsdm: "bxxk", lcmc: "通识必修" }); }); +test("selection mutation transport failures distinguish known pre-send failure from uncertain submission", async () => { + const preview = buildSelectionPreview({ semester:SEMESTER, cultivation:"1", currentTerm:{} }, { + operation:"cart.add", + courseId:"selection-id", + rwh:"task-id", + round:"bxxk", + bid:5, + where:"cart", + clientRequestId:"00000000-0000-4000-8000-000000000001", + }); + const beforeSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_ERROR", 1, { requestPhase:"before-send" }); + }, + } as unknown as TisSession); + await assert.rejects( + beforeSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_NOT_SUBMITTED" + && error.exitCode === 4 + && error.details?.warning === "NO_MUTATION_PERFORMED", + ); + + const afterSend = new TisClient({ + async postForm(): Promise { + throw new CliError("synthetic", "NETWORK_TIMEOUT", 1); + }, + } as unknown as TisSession); + await assert.rejects( + afterSend.selectionWrite(preview), + (error: unknown) => error instanceof CliError + && error.code === "TIS_SELECTION_OUTCOME_UNKNOWN" + && error.exitCode === 5 + && error.details?.warning === "DO_NOT_RETRY_AUTOMATICALLY" + && (error.details.target as { clientRequestId?: string; rwh?: string })?.clientRequestId === preview.clientRequestId + && (error.details.target as { rwh?: string })?.rwh === "task-id" + && (error.details.target as { round?: string })?.round === "bxxk" + && (error.details.target as { bid?: number })?.bid === 5 + && (error.details.target as { semester?: string })?.semester === SEMESTER.value + && (error.details.target as { where?: string })?.where === "cart", + ); +}); + test("grade filtering accepts both compact and descriptive TIS semester labels", async () => { const session = { async postJson(): Promise { diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index 832b395..5278e13 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -20,8 +20,16 @@ class MemoryStore implements SecretStore { public readonly backend = "macos-keychain" as const; public readonly persistent = true as const; public readonly values = new Map(); + public getCalls = 0; + public hasCalls = 0; + + public async has(account: string): Promise { + this.hasCalls += 1; + return this.values.has(account); + } public async get(account: string): Promise { + this.getCalls += 1; return this.values.get(account); } @@ -60,11 +68,14 @@ test("system credential profiles keep only non-secret metadata on disk", async ( backend: "macos-keychain", }); + const getCallsBeforeStatus = store.getCalls; const status = await getCredentialStatus("personal", { configDir, store }); assert.equal(status.configured, true); assert.equal(status.credentialAvailable, true); assert.equal(status.maskedSid, "12****00"); assert.deepEqual(status.profiles, ["personal"]); + assert.equal(store.getCalls, getCallsBeforeStatus); + assert.equal(store.hasCalls, 1); const deleted = await deleteStoredCredentials("personal", { configDir, store }); assert.equal(deleted.removed, true); @@ -90,6 +101,40 @@ test("saving a named profile never changes the implicit default profile", async } }); +test("an unverified credential write is rolled back before metadata is committed", async () => { + const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-keyring-verify-")); + const store = new MemoryStore(); + const normalGet = store.get.bind(store); + const normalSet = store.set.bind(store); + let hideNextRead = false; + store.set = async (account: string, password: string) => { + await normalSet(account, password); + hideNextRead = true; + }; + store.get = async (account: string) => { + if (hideNextRead) { + hideNextRead = false; + return undefined; + } + return normalGet(account); + }; + try { + await assert.rejects( + saveStoredCredentials({ sid:"12410000", password:"secret" }, { configDir, store }), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "Credential store write could not be verified by an immediate read-back.", + ); + assert.equal(store.values.size, 0); + await assert.rejects(readFile(join(configDir, "credentials.json"), "utf8"), (error: unknown) => { + assert.equal((error as NodeJS.ErrnoException).code, "ENOENT"); + return true; + }); + } finally { + await rm(configDir, { recursive:true, force:true }); + } +}); + test("Blackboard calendar links are stored by profile without on-disk metadata", async () => { const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-bb-calendar-keyring-")); const store = new MemoryStore(); @@ -260,6 +305,13 @@ case "$1" in printf '%s' "$password" > "$FAKE_SECRET_STATE" ;; lookup) + if [ "$FAKE_SECRET_LOOKUP_HANG" = "1" ]; then + exec /bin/sleep 60 + fi + if [ "$FAKE_SECRET_LOOKUP_LOCKED" = "1" ]; then + printf 'The collection is locked\\n' >&2 + exit 1 + fi if [ -s "$FAKE_SECRET_STATE" ]; then /bin/cat "$FAKE_SECRET_STATE" printf '\\n' @@ -290,6 +342,27 @@ esac assert.equal(loaded.password, "secret with spaces"); assert.equal(loaded.backend, "linux-secret-service"); + fakeEnv.FAKE_SECRET_LOOKUP_HANG = "1"; + const startedAt = Date.now(); + const timedOut = await getCredentialStatus(undefined, { ...storeOptions, credentialCommandTimeoutMs: 50 }); + assert.ok(Date.now() - startedAt < 2_000); + assert.equal(timedOut.credentialAvailable, false); + assert.equal(timedOut.backendAvailable, false); + assert.equal(timedOut.reasonCode, "CREDENTIAL_STORE_TIMEOUT"); + assert.match(timedOut.reason ?? "", /CREDENTIAL_STORE_TIMEOUT/); + delete fakeEnv.FAKE_SECRET_LOOKUP_HANG; + + fakeEnv.FAKE_SECRET_LOOKUP_LOCKED = "1"; + await assert.rejects( + loadStoredCredentials(undefined, storeOptions), + (error: unknown) => error instanceof CliError + && error.code === "CREDENTIAL_STORE_ERROR" + && error.details?.reason === "The Secret Service collection is locked." + && typeof error.details.remediation === "string" + && /Unlock/.test(error.details.remediation), + ); + delete fakeEnv.FAKE_SECRET_LOOKUP_LOCKED; + fakeEnv.FAKE_SECRET_CLEAR_ERROR = "1"; await assert.rejects( deleteStoredCredentials(undefined, storeOptions), diff --git a/src/test/planning-projection.test.ts b/src/test/planning-projection.test.ts new file mode 100644 index 0000000..d37f8ae --- /dev/null +++ b/src/test/planning-projection.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertPlanningProjection, + projectDegreeMissingForPlanning, + projectDegreeProgressForPlanning, + projectEnrollmentForPlanning, + projectSelectionRoundForPlanning, +} from "../tis/planning-projection.js"; +import type { TisDegreeMissing } from "../tis/degree-missing.js"; +import type { TisDegreeProgress } from "../tis/degree-progress.js"; +import { normalisePersonalScheduleEntry } from "../tis/normalise.js"; + +test("SKSJ-only enrollment retains teaching and meeting fields through the planning projection", () => { + const source = { + KCDM: "CS101", RWH: "task-1", KEY: "xq3_jc7", + SKSJ: "Synthetic course\n[Example Teacher]\n[Section A]\n[1-3周][Room 101][7-8节]", + }; + const result = projectEnrollmentForPlanning([normalisePersonalScheduleEntry(source)])[0]!; + assert.deepEqual(result.teachingTeam, ["Example Teacher"]); + assert.deepEqual(result.meetings, [{ day: 3, periodStart: 7, periodEnd: 8, weeks: [1, 2, 3], room: "Room 101" }]); + assert.equal("description" in result, false); + const explicit = normalisePersonalScheduleEntry({ ...source, SKJS: "Explicit Teacher", SKDD: "Room 202", ZC: "00101", KSJC: 1, JSJC: 2 }); + assert.equal(explicit.teacher, "Explicit Teacher"); + assert.equal(explicit.room, "Room 202"); + assert.deepEqual(explicit.weeks, [2, 4]); + assert.equal(explicit.periodEnd, 2); +}); + +test("planning degree projections are grade-free unless details were explicit", () => { + const progress = { + schemaVersion:"1", kind:"tis-degree-progress", reportedAt:"2026-09-02T00:00:00.000Z", + context:{ major:"Synthetic" }, summary:{ remainingCredits:3 }, creditCategories:[], moduleRequirements:[], moduleGaps:[], + dataAvailable:true, detailsRequested:true, detailsIncluded:true, + courses:[{ code:"CS101", name:"Synthetic", credits:3, letterGrade:"A", numericScore:95 }], courseCount:1, + sourceStatuses:{ + graduationRequirements:{ state:"available", count:1 }, requirementSummary:{ state:"available", count:1 }, + creditCategories:{ state:"empty", count:0 }, moduleRequirements:{ state:"empty", count:0 }, courses:{ state:"available", count:1 }, + }, warnings:[], + } satisfies TisDegreeProgress; + const safe = projectDegreeProgressForPlanning(progress); + assert.equal("letterGrade" in safe.courses![0]!, false); + assert.equal("numericScore" in safe.courses![0]!, false); + const explicit = projectDegreeProgressForPlanning(progress, { includeGrades:true }); + assert.equal(explicit.courses![0]!.numericScore, 95); +}); + +test("degree-missing and enrollment projections expose planning fields without raw grades or descriptions", () => { + const report = { + schemaVersion:"1", kind:"tis-degree-missing", generatedAt:"2026-09-02T00:00:00.000Z", reportedAt:"2026-09-02T00:00:00.000Z", + context:{}, officialSummary:{}, summary:{}, + advisory:{ primaryReference:"applicable-official-cultivation-plan", message:"Synthetic", contact:"Synthetic" }, + definiteMissingRequiredCourses:[{ + code:"CS101", name:"Synthetic", groups:[], categories:[], reason:"not complete", + latestAttempt:{ semester:"2025-2026-1", letterGrade:"F", numericScore:40, completion:"failed" }, + }], + inProgressRequiredCourses:[], choiceGaps:[], manualReview:[], + counts:{ definiteMissingRequiredCourses:1, inProgressRequiredCourses:0, choiceGaps:0, manualReview:0 }, + sourceStatuses:{ + progressDetails:{ state:"available", count:1 }, + progress:{ graduationRequirements:{state:"available"}, requirementSummary:{state:"available"}, creditCategories:{state:"empty"}, moduleRequirements:{state:"empty"}, courses:{state:"available"} }, + grades:{ state:"available", count:1 }, enrolled:{ state:"available", count:1 }, + }, warnings:[], + } as TisDegreeMissing; + const projected = projectDegreeMissingForPlanning(report); + assert.deepEqual(projected.definiteMissingRequiredCourses[0]?.latestAttempt, { + semester:"2025-2026-1", completion:"failed", + }); + + const enrollment = projectEnrollmentForPlanning([{ + rwh:"task-1", key:"xq1_jc1", courseCode:"CS101", courseName:"Synthetic", teacher:"Example Teacher", room:"Room 1", + description:"unrelated broad text", descriptionEn:"unrelated broad text", day:1, periodStart:1, periodEnd:2, weeks:[1, 2], + }]); + assert.deepEqual(Object.keys(enrollment[0]!).sort(), ["courseCode", "courseName", "meetings", "rwh", "teachingTeam"]); + assert.equal(JSON.stringify(enrollment).includes("unrelated broad text"), false); +}); + +test("planning projection guard rejects secret and unrelated identity fields", () => { + assert.throws(() => assertPlanningProjection({ courseCode:"CS101", token:"secret" }), /forbidden field/); + assert.throws(() => assertPlanningProjection({ studentId:"123" }), /forbidden field/); +}); + +test("selection-round projection keeps only planning metadata", () => { + assert.deepEqual(projectSelectionRoundForPlanning({ + xkfsdm:"bxxk", lcmc:"Synthetic round", jffs:"20", studentId:"not-for-output", unrelated:"drop", + }), { code:"bxxk", name:"Synthetic round", bidLimit:20 }); +}); diff --git a/src/test/selection-bundles.test.ts b/src/test/selection-bundles.test.ts new file mode 100644 index 0000000..064765a --- /dev/null +++ b/src/test/selection-bundles.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normaliseCourse } from "../tis/normalise.js"; +import { bundleSelectionCourses, retainCourseSourceRecord } from "../tis/selection-bundles.js"; + +test("lecture/lab rows become one selectable bundle with credits counted once", () => { + const lecture = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lecture", + componentRequired: true, + id: "selection-lecture", + rwh: "task-lecture", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lecture A", + xf: 3, + dgjsmc: "Example Lecturer", + kcxx: '

1-16周,星期一第1-2节 Room 101

', + }); + const lab = normaliseCourse({ + bundleId: "CS101-A", + componentType: "lab", + componentRequired: true, + id: "selection-lab", + rwh: "task-lab", + kcdm: "CS101", + kcmc: "Synthetic Systems", + kxh: "A", + rwmc: "Lab A", + xf: 3, + dgjsmc: "Example Lab Teacher", + kcxx: '

1-16双周,星期三第5-6节 Lab 201

', + }); + + const bundles = bundleSelectionCourses([lab, lecture, structuredClone(lab)]); + assert.equal(bundles.length, 1); + const bundle = bundles[0]!; + assert.equal(bundle.credits, 3); + assert.equal(bundle.components.length, 2); + assert.equal(bundle.components.filter((component) => component.creditBearing).length, 1); + assert.deepEqual(bundle.requiredComponentIds, ["task-lecture", "task-lab"]); + assert.deepEqual(bundle.teachingTeam, ["Example Lab Teacher", "Example Lecturer"]); + assert.deepEqual(bundle.meetings.find((meeting) => meeting.componentType === "lab")?.weeks, [2, 4, 6, 8, 10, 12, 14, 16]); + assert.deepEqual(bundle.operationTargets.map((target) => ({ + componentId: target.componentId, + courseId: target.mutationCourseId, + rwh: target.taskId, + payload: target.mutationPayloadField, + })), [ + { componentId: "task-lecture", courseId: "selection-lecture", rwh: "task-lecture", payload: "p_id" }, + { componentId: "task-lab", courseId: "selection-lab", rwh: "task-lab", payload: "p_id" }, + ]); + assert.equal(bundle.selectableWithoutGuessing, true); + assert.ok(bundle.warnings.some((warning) => /Duplicate source row/.test(warning))); +}); + +test("conflicting component credits fail closed instead of being summed or guessed", () => { + const first = normaliseCourse({ bundleId:"X", id:"id-a", rwh:"task-a", kcdm:"X", kcmc:"X", xf:2 }); + const second = normaliseCourse({ bundleId:"X", id:"id-b", rwh:"task-b", kcdm:"X", kcmc:"X", xf:3 }); + const bundle = bundleSelectionCourses([first, second])[0]!; + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); +}); + +test("raw selection records require the diagnostics-only envelope", () => { + const source = { id:"selection-id", rwh:"task-id", unknownPersonalField:"not-for-default-json" }; + const diagnostic = retainCourseSourceRecord(source); + source.unknownPersonalField = "changed"; + assert.equal(diagnostic.kind, "tis-selection-source-record"); + assert.equal(diagnostic.raw.unknownPersonalField, "not-for-default-json"); +}); + +test("duplicate-component credit conflicts remain ambiguous in either source order", () => { + for (const creditBearing of [undefined, true]) { + const first = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 2, creditBearing }); + const second = normaliseCourse({ bundleId: "X", id: "id-a", rwh: "task-a", kcdm: "X", kcmc: "X", xf: 3, creditBearing }); + for (const rows of [[first, second], [second, first]]) { + const bundle = bundleSelectionCourses(rows)[0]!; + assert.equal(bundle.components.length, 1); + assert.equal(bundle.credits, undefined); + assert.equal(bundle.creditStatus, "ambiguous"); + assert.equal(bundle.components.some((component) => component.creditBearing), false); + assert.equal(bundle.selectableWithoutGuessing, false); + } + } +}); diff --git a/src/test/tis-remaining-calendar.test.ts b/src/test/tis-remaining-calendar.test.ts index 6658a74..85fd24a 100644 --- a/src/test/tis-remaining-calendar.test.ts +++ b/src/test/tis-remaining-calendar.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; +import { CalendarTerm } from "../calendar/client.js"; import { buildIcsContent, buildScheduleIcs, @@ -10,6 +11,7 @@ import { nearestUpcomingExam, parseIsoDateTimeToUtcStamp, parseShenzhenExamTimeRange, + PERIOD_START_TIMES, scheduleOccurrences, summariseCurrentOrNextClass, teachingPeriodAtShenzhenTime, @@ -34,6 +36,29 @@ const ENTRIES: PersonalScheduleEntry[] = [ }, ]; +const FALL_2026 = new CalendarTerm({ + season: "fall", + level: "undergraduate", + humanName: "2026 Fall", + semester: { xn: "2026-2027", xq: "1", value: "2026-2027-1" }, + start: "2026-09-01", + end: "2027-01-11", + signIn: "2026-09-04", + teachingStart: "2026-09-07", + teachingEnd: "2026-12-27", + totalTeachingWeeks: 16, + midterm: { start: "2026-10-26", end: "2026-11-08", equivalentWeeks: [8, 9] }, + final: { start: "2026-12-28", end: "2027-01-08", equivalentWeeks: [17] }, + compensatories: [ + { date: "2026-09-20", weekType: "odd", workday: "Friday" }, + { date: "2026-10-10", weekType: "odd", workday: "Wednesday" }, + ], + extraBreaks: ["2026-11-20"], +}, [ + { name: "Mid-Autumn Festival", start: "2026-09-25", end: "2026-09-27" }, + { name: "National Day", start: "2026-10-01", end: "2026-10-07" }, +]); + test("week-one inference backtracks from today's week index to the semester anchor", () => { assert.equal(inferWeekOneMonday("2026-03-04", 2), "2026-02-23"); }); @@ -56,7 +81,7 @@ test("ICS export expands schedule entries into dated UTC events", () => { assert.match(ics, /LOCATION:智华楼102/); }); -test("ICS export supports the thirteenth TIS period", () => { +test("legacy ICS export keeps the historical thirteenth TIS period", () => { const lateEntry: PersonalScheduleEntry = { ...ENTRIES[0], key: "xq1_jc13", @@ -69,6 +94,76 @@ test("ICS export supports the thirteenth TIS period", () => { assert.equal(occurrence?.endUtc, "20260223T145000Z"); }); +test("2026 fall uses the current SUSTech period schedule", () => { + assert.deepEqual(PERIOD_START_TIMES, { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], + }); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T06:05:00Z"))?.periodLabel ?? "", + /P5 14:00-14:50/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T08:25:00Z"))?.periodLabel ?? "", + /P7 16:20-17:10/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T13:05:00Z"))?.periodLabel ?? "", + /P11 21:00-21:50/, + ); + assert.equal(teachingPeriodAtShenzhenTime(new Date("2026-09-08T14:05:00Z")), undefined); +}); + +test("calendar-adjusted occurrences move odd-week classes onto compensatory days", () => { + const friday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "FRIDAY", + key: "xq5_jc5", + day: 5, + periodStart: 5, + periodEnd: 6, + weeks: [3], + }; + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "WEDNESDAY", + key: "xq3_jc7", + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holidayOnly: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "MONDAY", + key: "xq1_jc1", + day: 1, + periodStart: 1, + periodEnd: 2, + weeks: [5], + }; + + const occurrences = scheduleOccurrences( + [friday, wednesday, holidayOnly], + { teachingStartDate: FALL_2026.snapshot.teachingStart }, + FALL_2026, + ); + assert.deepEqual(occurrences.map((entry) => entry.date), ["2026-09-20", "2026-10-10"]); + assert.deepEqual(occurrences.map((entry) => entry.sourceDate), ["2026-09-25", "2026-10-07"]); + assert.ok(occurrences.every((entry) => entry.isCompensatory)); + assert.equal(occurrences[0]?.startUtc, "20260920T060000Z"); + assert.equal(occurrences[1]?.endUtc, "20261010T101000Z"); +}); + test("teaching-period lookup uses Asia/Shanghai wall clock boundaries", () => { const period = teachingPeriodAtShenzhenTime(new Date("2026-08-26T02:25:00Z")); assert.equal(period?.date, "2026-08-26"); @@ -134,6 +229,30 @@ test("current-or-next class summary distinguishes active and upcoming classes", assert.match(upcoming.nextDetail ?? "", /week 4 Wednesday|today|tomorrow/i); }); +test("current class summary follows compensatory dates instead of the holiday weekday", () => { + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holiday = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-07T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.equal(holiday.now, undefined); + assert.match(holiday.nextDetail ?? "", /Saturday \(makeup for Wednesday\)/); + + const makeup = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-10T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.match(makeup.now ?? "", /today \(makeup for Wednesday\).*16:20-18:10/); +}); + test("nearest upcoming exam keeps exact-order semantics and reports omitted malformed rows", () => { const exams: ExamRecord[] = [ { diff --git a/src/test/tis-remaining-selection.test.ts b/src/test/tis-remaining-selection.test.ts index a4c221c..493d609 100644 --- a/src/test/tis-remaining-selection.test.ts +++ b/src/test/tis-remaining-selection.test.ts @@ -7,6 +7,7 @@ import { ensureSelectionVerified, planBidUpdates, projectBidTotal, + reconcileSelectionSnapshots, revalidateSelectionWrite, selectionEndpoint, verifySelectionWrite, @@ -29,6 +30,10 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.equal(enroll.endpoint, "/Xsxk/addXuanke"); assert.equal(enroll.payload.p_xktjz, "gwctjzyx"); assert.equal(enroll.payload.p_xkxs, "3"); + assert.match(enroll.clientRequestId, /^[0-9a-f-]{36}$/); + assert.deepEqual(enroll.identifierContract.readbackIdentity, ["courseId", "rwh"]); + assert.equal(enroll.idempotency.upstreamKeySupported, false); + assert.equal(enroll.idempotency.automaticRetry, "forbidden"); const cartAdd = buildSelectionPreview(CONTEXT, { operation: "cart.add", @@ -48,6 +53,38 @@ test("selection previews keep write payloads typed and operation-specific", () = assert.match(cartBid.successHeuristic, /cart bid value/i); }); +test("bounded reconciliation handles delayed visibility without repeating a mutation", () => { + const target = { + operation: "cart.add" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "bxxk", + bid: 1, + where: "cart" as const, + }; + const absent = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const present = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"1" }], enrolled:[], round:{ xkfsdm:"bxxk" } }; + const result = reconcileSelectionSnapshots([absent, absent, present], target); + assert.equal(result.status, "applied"); + assert.equal(result.automaticRetryAllowed, false); +}); + +test("bounded reconciliation distinguishes stable not-applied from conflicting readback", () => { + const target = { + operation: "bid.update" as const, + courseId: "hex-id", + rwh: "RWH-1", + round: "yixuan", + bid: 5, + where: "cart" as const, + }; + const unchanged = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"hex-id", rwh:"RWH-1", xkxs:"2" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, structuredClone(unchanged)], target).status, "not_applied"); + + const conflicting = { courses:[{ id:"hex-id", rwh:"RWH-1" }], cart:[{ id:"other-id", rwh:"RWH-1", xkxs:"5" }], enrolled:[], round:{ xkfsdm:"yixuan" } }; + assert.equal(reconcileSelectionSnapshots([unchanged, conflicting], target).status, "still_uncertain"); +}); + test("bid planning short-circuits when the round budget would be exceeded", () => { const plan = planBidUpdates(CONTEXT, { A: 5, B: 4 }, { where: "cart", round: "yixuan", limit: 8 }); assert.equal(plan.overLimit, true); @@ -55,6 +92,24 @@ test("bid planning short-circuits when the round budget would be exceeded", () = assert.equal(plan.previews.length, 0); }); +test("reconciliation requires matching round evidence for every operation", () => { + for (const operation of ["cart.add", "cart.remove", "enroll", "drop", "bid.update"] as const) { + const target = { operation, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + for (const round of [{ xkfsdm: "yixuan" }, {}]) { + const entries = [{ id: "hex-id", rwh: "RWH-1", xkxs: "5" }]; + const state = { cart: entries, enrolled: entries, round }; + assert.equal(reconcileSelectionSnapshots([state, structuredClone(state)], target).status, "still_uncertain"); + assert.equal(verifySelectionWrite(state, target).status, "not_observed"); + } + } +}); + +test("a missing bid value cannot establish the inverse mutation state", () => { + const target = { operation: "bid.update" as const, courseId: "hex-id", rwh: "RWH-1", round: "bxxk", bid: 5, where: "cart" as const }; + const state = { cart: [{ id: "hex-id", rwh: "RWH-1" }], enrolled: [], round: { xkfsdm: "bxxk" } }; + assert.equal(reconcileSelectionSnapshots([state, state], target).status, "still_uncertain"); +}); + test("bid planning validates per-course bids before generating previews", () => { const plan = planBidUpdates(CONTEXT, { A: 0, B: 2 }, { where: "enrolled", round: "yixuan" }); assert.deepEqual(plan.errors, ["A: bid must be >= 1"]); diff --git a/src/tis/client.ts b/src/tis/client.ts index fb31bf5..163a45c 100644 --- a/src/tis/client.ts +++ b/src/tis/client.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { CliError } from "../core/errors.js"; @@ -26,6 +27,7 @@ import { type EvaluationStatusFilter, } from "./remaining-evaluation.js"; import type { SelectionPreview } from "./remaining-selection.js"; +import { bundleSelectionCourses, type SelectionCourseBundle } from "./selection-bundles.js"; import type { Course, ExamRecord, @@ -82,14 +84,24 @@ export class TisClient { public async searchAvailable( semester: Semester, options: { keyword?: string; round: string; limit: number }, - ): Promise<{ courses: Course[]; total: number; enrolled: unknown[]; cart: unknown[]; round: Record }> { + ): Promise<{ + courses: Course[]; + bundles: SelectionCourseBundle[]; + total: number; + enrolled: unknown[]; + cart: unknown[]; + round: Record; + reportedAt: string; + }> { const state = await this.selectionState(semester, options); return { courses: state.courses, + bundles: bundleSelectionCourses(state.courses), total: state.total, enrolled: state.enrolled, cart: state.cart, round: state.round, + reportedAt: new Date().toISOString(), }; } @@ -293,18 +305,33 @@ export class TisClient { public async addCourse(input: { semester: Semester; courseId: string; + rwh: string; round: string; bid: number; cultivation: "1" | "2"; }): Promise { + const clientRequestId = randomUUID(); const dq = asRecord(await this.session.postForm("/Xsxk/queryXkdqXnxq", {})); - const response = asRecord( - await this.session.postForm( + let response: Record; + try { + response = asRecord(await this.session.postForm( "/Xsxk/addXuanke", buildWritePayload(input, dq), - ), - ); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + )); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId, + operation: "enroll", + courseId: input.courseId, + rwh: input.rwh, + round: input.round, + bid: input.bid, + semester: input.semester.value, + cultivation: input.cultivation, + where: "enrolled", + }); + } + return { clientRequestId, jg: stringValue(response.jg), message: stringValue(response.message) }; } public async selectionWrite(preview: SelectionPreview): Promise { @@ -314,8 +341,21 @@ export class TisClient { endpoint: preview.endpoint, }); } - const response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); - return { jg: stringValue(response.jg), message: stringValue(response.message), raw: response }; + let response: Record; + try { + response = asRecord(await this.session.postForm(preview.endpoint, preview.payload)); + } catch (error) { + throw mutationTransportError(error, { + clientRequestId: preview.clientRequestId, + operation: preview.operation, + ...preview.exactTarget, + }); + } + return { + clientRequestId: preview.clientRequestId, + jg: stringValue(response.jg), + message: stringValue(response.message), + }; } private async fetchCatalog(semester: Semester): Promise { @@ -567,3 +607,37 @@ function selectionWriteAllowed(preview: SelectionPreview): boolean { } return false; } + +function mutationTransportError( + error: unknown, + target: Record & { clientRequestId: string; operation: string }, +): CliError { + const requestPhase = error instanceof CliError && error.details?.requestPhase === "before-send" + ? "before-send" + : "submission-may-have-started"; + if (requestPhase === "before-send") { + return new CliError( + "The selection request failed before submission; no mutation was performed.", + "TIS_SELECTION_NOT_SUBMITTED", + 4, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "NO_MUTATION_PERFORMED", + }, + ); + } + return new CliError( + "The selection request lost a conclusive response after submission may have started.", + "TIS_SELECTION_OUTCOME_UNKNOWN", + 5, + { + target, + upstreamCode: error instanceof CliError ? error.code : "UNKNOWN", + requestPhase, + warning: "DO_NOT_RETRY_AUTOMATICALLY", + next: "Run `sustech tis selection reconcile` for this exact courseId/rwh/round target; do not repeat the mutation.", + }, + ); +} diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index c164111..f8fa257 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -1,4 +1,12 @@ -import type { Course, ExamRecord, GradeRecord, PersonalScheduleEntry, ScheduleSlot } from "./types.js"; +import type { + Course, + CourseComponentType, + CourseSelectionContract, + ExamRecord, + GradeRecord, + PersonalScheduleEntry, + ScheduleSlot, +} from "./types.js"; const DAY_CHARS = "一二三四五六日"; const DAY_NAMES = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]; @@ -9,6 +17,7 @@ export function normaliseCourse(raw: Record): Course { const rwh = stringValue(raw.rwh); const classGroup = stringValue(raw.kxh) || rwh.split("-").at(-1) || ""; const teachers = splitTeachers(stringValue(raw.dgjsmc)); + const selection = normaliseCourseSelectionContract(raw, rwh); return { code: stringValue(raw.kcdm), @@ -30,6 +39,36 @@ export function normaliseCourse(raw: Record): Course { language: stringValue(raw.skyymc), teachers, schedule, + ...(selection ? { selection } : {}), + }; +} + +export function normaliseCourseSelectionContract( + raw: Record, + taskId = stringValue(raw.rwh), +): CourseSelectionContract | undefined { + if (!taskId) return undefined; + const mutationId = firstString(raw, ["id", "courseId", "course_id"]); + const explicitBundleId = firstString(raw, [ + "bundleId", "bundle_id", "kczid", "KCZID", "zhrwid", "ZHRWID", "parentRwh", "PARENT_RWH", + ]); + const taskType = firstString(raw, ["componentType", "component_type", "rwlxmc", "rwlx"]); + const required = firstBoolean(raw, ["componentRequired", "component_required", "required", "sfbd"]); + const creditBearing = firstBoolean(raw, ["creditBearing", "credit_bearing", "sfxfjl"]); + return { + bundleId: explicitBundleId + ? `tis-bundle:${explicitBundleId}` + : mutationId + ? `tis-selection:${mutationId}` + : `tis-task:${taskId}`, + componentId: taskId, + componentType: componentType(taskType), + required: required ?? true, + ...(creditBearing !== undefined ? { creditBearing } : {}), + identifiers: { + task: { field: "rwh", value: taskId }, + ...(mutationId ? { mutation: { field: "courseId" as const, payloadField: "p_id" as const, value: mutationId } } : {}), + }, }; } @@ -70,8 +109,16 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe const keyMatch = /^xq(\d+)_jc(\d+)/i.exec(key); const weekBitmap = firstString(raw, ["ZC", "zc"]); const description = firstString(raw, ["SKSJ", "sksj"]); - const periodStart = numberValue(raw.KSJC ?? raw.ksjc) ?? (keyMatch ? Number(keyMatch[2]) : undefined); - const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) ?? periodStart; + const descriptionLines = description.split(/\r?\n/).map((line) => line.trim()); + const descriptionTeacher = /^\[([^\[\]]+)\]$/.exec(descriptionLines[1] ?? "")?.[1] ?? ""; + const descriptionMeeting = /\[([\d,,、\s-]+)(单|双)?周\]\s*\[([^\[\]]*)\]\s*\[(\d+)(?:-(\d+))?节\]/.exec(description); + let descriptionWeeks = descriptionMeeting ? expandWeeks(descriptionMeeting[1].replace(/[,、]/g, ",")) : []; + if (descriptionMeeting?.[2] === "单") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 1); + if (descriptionMeeting?.[2] === "双") descriptionWeeks = descriptionWeeks.filter((week) => week % 2 === 0); + const periodStart = numberValue(raw.KSJC ?? raw.ksjc) + ?? (keyMatch ? Number(keyMatch[2]) : descriptionMeeting ? Number(descriptionMeeting[4]) : undefined); + const periodEnd = numberValue(raw.JSJC ?? raw.jsjc) + ?? (descriptionMeeting ? Number(descriptionMeeting[5] ?? descriptionMeeting[4]) : periodStart); return { rwh: firstString(raw, ["RWH", "rwh"]), key, @@ -79,14 +126,14 @@ export function normalisePersonalScheduleEntry(raw: Record): Pe courseName: firstString(raw, ["KCMC", "kcmc", "KCWZSM", "kcwzsm", "name"]) || description.split("\n")[0]?.trim() || "", - teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]), - room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]), + teacher: firstString(raw, ["SKJS", "DGJSMC", "dgjsmc", "teacher"]) || descriptionTeacher, + room: firstString(raw, ["SKDD", "JXDD", "JXCDMC", "room"]) || descriptionMeeting?.[3]?.trim() || "", description, descriptionEn: firstString(raw, ["SKSJ_EN", "sksj_en"]), ...(keyMatch ? { day: Number(keyMatch[1]) } : {}), ...(periodStart !== undefined ? { periodStart } : {}), ...(periodEnd !== undefined ? { periodEnd } : {}), - weeks: bitmapWeeks(weekBitmap), + weeks: weekBitmap ? bitmapWeeks(weekBitmap) : descriptionWeeks, }; } @@ -196,9 +243,31 @@ function firstString(record: Record, keys: string[]): string { return ""; } +function firstBoolean(record: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number" && (value === 0 || value === 1)) return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "required", "是", "必修"].includes(normalized)) return true; + if (["0", "false", "no", "optional", "否", "选修"].includes(normalized)) return false; + } + } + return undefined; +} + +function componentType(value: string): CourseComponentType { + const normalized = value.toLowerCase(); + if (/实验|lab/.test(normalized)) return "lab"; + if (/习题|辅导|tutorial/.test(normalized)) return "tutorial"; + if (/讲授|理论|lecture/.test(normalized)) return "lecture"; + return normalized ? "other" : "unknown"; +} + function bitmapWeeks(bitmap: string): number[] { return [...bitmap] - .map((enabled, index) => enabled === "1" ? index + 1 : undefined) + .map((enabled, index) => enabled === "1" && index > 0 ? index : undefined) .filter((week): week is number => week !== undefined); } diff --git a/src/tis/planning-projection.ts b/src/tis/planning-projection.ts new file mode 100644 index 0000000..5840586 --- /dev/null +++ b/src/tis/planning-projection.ts @@ -0,0 +1,167 @@ +import type { + DegreeMissingAttempt, + DegreeMissingRequiredCourse, + TisDegreeMissing, +} from "./degree-missing.js"; +import type { DegreeProgressCourse, TisDegreeProgress } from "./degree-progress.js"; +import type { PersonalScheduleEntry } from "./types.js"; + +export const PLANNING_PROJECTION_FIELDS = Object.freeze({ + degreeProgress: ["context", "summary", "creditCategories", "moduleRequirements", "moduleGaps", "sourceStatuses", "reportedAt"], + degreeCourse: ["code", "name", "group", "college", "semester", "required", "credits", "hours", "courseNature", "category", "majorTrack"], + enrollment: ["courseCode", "courseName", "rwh", "teachingTeam", "meetings"], + availability: ["bundleId", "courseCode", "courseName", "credits", "components", "teachingTeam", "meetings", "operationTargets", "reportedAt"], +} as const); + +export type GradeFreeDegreeAttempt = Omit; +export type GradeFreeMissingCourse = Omit & { + latestAttempt?: GradeFreeDegreeAttempt; + previousAttempt?: GradeFreeDegreeAttempt; +}; + +export type PlanningDegreeMissing = Omit & { + definiteMissingRequiredCourses: GradeFreeMissingCourse[]; + inProgressRequiredCourses: GradeFreeMissingCourse[]; + projection: { mode: "planning-grade-free"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS }; +}; + +export interface PlanningEnrollment { + courseCode: string; + courseName: string; + rwh: string; + teachingTeam: string[]; + meetings: Array<{ + day?: number; + periodStart?: number; + periodEnd?: number; + weeks: number[]; + room: string; + }>; +} + +export interface PlanningSelectionRound { + code?: string; + name?: string; + bidLimit?: number; +} + +export function projectSelectionRoundForPlanning(raw: Record): PlanningSelectionRound { + const code = text(raw.xkfsdm); + const name = text(raw.lcmc ?? raw.xkfslxmc ?? raw.name); + const bidLimit = numeric(raw.jffs); + const projected = { + ...(code ? { code } : {}), + ...(name ? { name } : {}), + ...(bidLimit !== undefined ? { bidLimit } : {}), + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeProgressForPlanning( + progress: TisDegreeProgress, + options: { includeGrades?: boolean } = {}, +): TisDegreeProgress & { projection: { mode: "planning-grade-free" | "explicit-details"; fieldAllowlist: typeof PLANNING_PROJECTION_FIELDS } } { + const courses = progress.courses?.map((course) => options.includeGrades ? { ...course } : gradeFreeCourse(course)); + const projected = { + ...progress, + ...(courses ? { courses } : {}), + projection: { + mode: options.includeGrades ? "explicit-details" as const : "planning-grade-free" as const, + fieldAllowlist: PLANNING_PROJECTION_FIELDS, + }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectDegreeMissingForPlanning(report: TisDegreeMissing): PlanningDegreeMissing { + const projected: PlanningDegreeMissing = { + ...report, + definiteMissingRequiredCourses: report.definiteMissingRequiredCourses.map(gradeFreeMissingCourse), + inProgressRequiredCourses: report.inProgressRequiredCourses.map(gradeFreeMissingCourse), + projection: { mode:"planning-grade-free", fieldAllowlist:PLANNING_PROJECTION_FIELDS }, + }; + assertPlanningProjection(projected); + return projected; +} + +export function projectEnrollmentForPlanning(entries: readonly PersonalScheduleEntry[]): PlanningEnrollment[] { + const grouped = new Map(); + for (const entry of entries) { + const key = `${entry.courseCode}\u0000${entry.rwh}`; + const current = grouped.get(key) ?? { + courseCode: entry.courseCode, + courseName: entry.courseName, + rwh: entry.rwh, + teachingTeam: [], + meetings: [], + }; + current.teachingTeam = unique([...current.teachingTeam, entry.teacher]); + current.meetings.push({ + ...(entry.day !== undefined ? { day:entry.day } : {}), + ...(entry.periodStart !== undefined ? { periodStart:entry.periodStart } : {}), + ...(entry.periodEnd !== undefined ? { periodEnd:entry.periodEnd } : {}), + weeks: [...entry.weeks], + room: entry.room, + }); + grouped.set(key, current); + } + const projected = [...grouped.values()].sort((left, right) => left.courseCode.localeCompare(right.courseCode) || left.rwh.localeCompare(right.rwh)); + assertPlanningProjection(projected); + return projected; +} + +export function assertPlanningProjection(value: unknown): void { + visit(value, "$", new Set()); +} + +function visit(value: unknown, path: string, seen: Set): void { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${path}[${index}]`, seen)); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (/^(?:password|passwd|authorization|cookie|cookies|token|accessToken|refreshToken|sid|studentId|studentNumber|raw)$/i.test(key)) { + throw new Error(`Planning projection contains forbidden field ${path}.${key}.`); + } + visit(entry, `${path}.${key}`, seen); + } +} + +function gradeFreeCourse(course: DegreeProgressCourse): DegreeProgressCourse { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = course; + return projected; +} + +function gradeFreeMissingCourse(course: DegreeMissingRequiredCourse): GradeFreeMissingCourse { + const { latestAttempt, previousAttempt, ...projected } = course; + return { + ...projected, + ...(latestAttempt ? { latestAttempt:gradeFreeAttempt(latestAttempt) } : {}), + ...(previousAttempt ? { previousAttempt:gradeFreeAttempt(previousAttempt) } : {}), + }; +} + +function gradeFreeAttempt(attempt: DegreeMissingAttempt): GradeFreeDegreeAttempt { + const { letterGrade: _letterGrade, numericScore: _numericScore, ...projected } = attempt; + return projected; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function text(value: unknown): string | undefined { + const result = typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; + return result || undefined; +} + +function numeric(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const result = Number(value); + return Number.isFinite(result) ? result : undefined; +} diff --git a/src/tis/remaining-calendar.ts b/src/tis/remaining-calendar.ts index 3124cda..3d86e0f 100644 --- a/src/tis/remaining-calendar.ts +++ b/src/tis/remaining-calendar.ts @@ -4,7 +4,8 @@ import { copyFile, link, lstat, open, rename, rm, stat } from "node:fs/promises" import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { CliError } from "../core/errors.js"; import { assertPathAndParentsAreNotSymlinks } from "../core/local-store.js"; -import type { Holiday } from "../calendar/types.js"; +import type { CalendarTerm } from "../calendar/client.js"; +import type { CompensatoryDay, Holiday, WeekdayName } from "../calendar/types.js"; import type { ExamRecord, PersonalScheduleEntry } from "./types.js"; import { addUtcDays, parseIsoDate, toIsoDate } from "./remaining-shared.js"; @@ -25,6 +26,9 @@ export interface IcsOccurrence { day: number; periodStart: number; periodEnd: number; + isCompensatory?: true; + sourceDate?: string; + sourceWeekday?: WeekdayName; } export interface IcsEvent { @@ -67,7 +71,21 @@ export interface TeachingPeriodWindow { } const CHINA_OFFSET_MINUTES = 8 * 60; +const CURRENT_PERIOD_SCHEDULE_START = "2026-09-07"; export const PERIOD_START_TIMES: Readonly> = { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], +}; +const LEGACY_PERIOD_START_TIMES: Readonly> = { 1: [8, 0], 2: [9, 0], 3: [10, 20], @@ -107,47 +125,134 @@ export function weekOneMondayFromAnchor(anchor: IcsAnchor): string { export function scheduleOccurrences( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, + calendarTerm?: CalendarTerm, ): IcsOccurrence[] { + return resolveScheduleOccurrences(entries, anchor, calendarTerm).map(({ entry: _entry, ...occurrence }) => occurrence); +} + +interface ResolvedScheduleOccurrence extends IcsOccurrence { + entry: PersonalScheduleEntry; +} + +function resolveScheduleOccurrences( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): ResolvedScheduleOccurrence[] { const weekOneMonday = parseIsoDate(weekOneMondayFromAnchor(anchor)); - const occurrences: IcsOccurrence[] = []; + const occurrences: ResolvedScheduleOccurrence[] = []; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; for (const week of [...entry.weeks].sort((left, right) => left - right)) { const date = addUtcDays(weekOneMonday, (week - 1) * 7 + (entry.day - 1)); - const start = periodStartUtc(date, entry.periodStart); - const end = periodEndUtc(date, entry.periodEnd); - const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() || entry.courseName || entry.courseCode || "SUSTech Class"; - const description = [entry.teacher, entry.description, `Week ${week}`].filter(Boolean).join(" | "); - occurrences.push({ - uid: `${entry.rwh || entry.key || summary}-${toIsoDate(date)}-p${entry.periodStart}@sustech-cli`, - summary, - location: entry.room || undefined, - description, - startUtc: start, - endUtc: end, - date: toIsoDate(date), - week, - day: entry.day, - periodStart: entry.periodStart, - periodEnd: entry.periodEnd, - }); + const dateText = toIsoDate(date); + if (calendarTerm && !regularClassRunsOn(calendarTerm, dateText)) continue; + occurrences.push(resolveOccurrence(entry, dateText, week)); + } + } + + if (calendarTerm) { + for (const compensatory of calendarTerm.snapshot.compensatories) { + const target = compensatoryTarget(calendarTerm, compensatory); + if (!target) continue; + const sourceDay = weekdayNumber(compensatory.workday); + for (const entry of entries) { + if ( + entry.day !== sourceDay + || entry.periodStart === undefined + || entry.periodEnd === undefined + || !entry.weeks.includes(target.week) + ) continue; + occurrences.push(resolveOccurrence(entry, compensatory.date, target.week, { + sourceDate: target.date, + sourceWeekday: compensatory.workday, + })); + } } } return occurrences.sort((left, right) => left.startUtc.localeCompare(right.startUtc) || left.uid.localeCompare(right.uid)); } +function resolveOccurrence( + entry: PersonalScheduleEntry, + date: string, + week: number, + makeup?: { sourceDate: string; sourceWeekday: WeekdayName }, +): ResolvedScheduleOccurrence { + const parsedDate = parseIsoDate(date); + const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() + || entry.courseName + || entry.courseCode + || "SUSTech Class"; + const makeupDescription = makeup + ? `Makeup for ${makeup.sourceWeekday} ${makeup.sourceDate}` + : ""; + return { + entry, + uid: `${entry.rwh || entry.key || summary}-${date}-p${entry.periodStart}@sustech-cli`, + summary, + location: entry.room || undefined, + description: [entry.teacher, entry.description, `Week ${week}`, makeupDescription].filter(Boolean).join(" | "), + startUtc: periodStartUtc(parsedDate, entry.periodStart!), + endUtc: periodEndUtc(parsedDate, entry.periodEnd!), + date, + week, + day: entry.day!, + periodStart: entry.periodStart!, + periodEnd: entry.periodEnd!, + ...(makeup ? { + isCompensatory: true as const, + sourceDate: makeup.sourceDate, + sourceWeekday: makeup.sourceWeekday, + } : {}), + }; +} + +function regularClassRunsOn(term: CalendarTerm, date: string): boolean { + const day = term.day(date); + return date >= term.snapshot.teachingStart + && date <= term.snapshot.teachingEnd + && !day.flags.isHoliday + && !day.flags.isExtraBreak + && !day.flags.isFinal; +} + +function compensatoryTarget( + term: CalendarTerm, + compensatory: CompensatoryDay, +): { week: number; date: string } | undefined { + const parity = compensatory.weekType === "odd" ? 1 : 0; + const compensatoryDate = parseIsoDate(compensatory.date); + return Array.from({ length: term.snapshot.totalTeachingWeeks }, (_, index) => index + 1) + .filter((week) => week % 2 === parity) + .map((week) => ({ week, date: term.dateOf(week, compensatory.workday) })) + .filter((candidate) => !regularClassRunsOn(term, candidate.date)) + .sort((left, right) => ( + Math.abs(parseIsoDate(left.date).getTime() - compensatoryDate.getTime()) + - Math.abs(parseIsoDate(right.date).getTime() - compensatoryDate.getTime()) + ))[0]; +} + +function weekdayNumber(weekday: WeekdayName): number { + return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].indexOf(weekday) + 1; +} + export function buildScheduleIcs( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, - options: { calendarName?: string; nowUtc?: Date } = {}, + options: { calendarName?: string; nowUtc?: Date; calendarTerm?: CalendarTerm } = {}, ): string { - return buildIcsContent(scheduleIcsEvents(entries, anchor), options); + return buildIcsContent(scheduleIcsEvents(entries, anchor, options.calendarTerm), options); } -export function scheduleIcsEvents(entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor): IcsEvent[] { - return scheduleOccurrences(entries, anchor).map((event) => ({ +export function scheduleIcsEvents( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): IcsEvent[] { + return scheduleOccurrences(entries, anchor, calendarTerm).map((event) => ({ uid: event.uid, summary: event.summary, description: event.description, @@ -277,39 +382,47 @@ export function parseShenzhenExamTimeRange(date: string, time: string): { startU export function summariseCurrentOrNextClass( entries: readonly PersonalScheduleEntry[], - options: { currentWeek: number; now: Date }, + options: { currentWeek: number; now: Date; calendarTerm?: CalendarTerm }, ): ScheduleReminderSummary { + if (options.calendarTerm) { + return summariseCalendarAdjustedClasses(entries, options.now, options.calendarTerm); + } + const clock = shenzhenWallClock(options.now); const currentMinute = clock.hour * 60 + clock.minute; - let active: PersonalScheduleEntry | undefined; - let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number } | undefined; + let active: { entry: PersonalScheduleEntry; week: number; date: string } | undefined; + let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number; date: string } | undefined; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; - const startSlot = PERIOD_START_TIMES[entry.periodStart]; - const endSlot = PERIOD_START_TIMES[entry.periodEnd]; - if (!startSlot || !endSlot) continue; - const startMinutes = startSlot[0] * 60 + startSlot[1]; - const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; for (const week of [...entry.weeks].sort((left, right) => left - right)) { if (week < options.currentWeek) continue; const dayOffset = (week - options.currentWeek) * 7 + (entry.day - clock.weekday); if (dayOffset < 0) continue; + const date = toIsoDate(addUtcDays(parseIsoDate(clock.date), dayOffset)); + const periods = periodStartTimesForDate(date); + const startSlot = periods[entry.periodStart]; + const endSlot = periods[entry.periodEnd]; + if (!startSlot || !endSlot) continue; + const startMinutes = startSlot[0] * 60 + startSlot[1]; + const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; if (dayOffset === 0 && startMinutes <= currentMinute && currentMinute < endMinutes) { - if (!active || startMinutes < periodStartMinutes(active.periodStart ?? 99)) active = entry; + if (!active || startMinutes < periodStartMinutes(active.entry.periodStart ?? 99, active.date)) { + active = { entry, week, date }; + } continue; } if (dayOffset === 0 && startMinutes <= currentMinute) continue; if (!next || dayOffset < next.dayOffset || (dayOffset === next.dayOffset && startMinutes < next.startMinutes)) { - next = { entry, week, dayOffset, startMinutes }; + next = { entry, week, dayOffset, startMinutes, date }; } break; } } - if (active) return { now: formatScheduleEntryLabel(active, options.currentWeek) }; + if (active) return { now: formatScheduleEntryLabel(active.entry, active.week, active.date) }; if (next) { - const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset); + const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset, next.date); return { next: formatScheduleEntryTitle(next.entry), nextDetail: detail, @@ -319,6 +432,44 @@ export function summariseCurrentOrNextClass( return {}; } +function summariseCalendarAdjustedClasses( + entries: readonly PersonalScheduleEntry[], + now: Date, + calendarTerm: CalendarTerm, +): ScheduleReminderSummary { + const clock = shenzhenWallClock(now); + const nowStamp = formatUtc(now); + const occurrences = resolveScheduleOccurrences( + entries, + { teachingStartDate: calendarTerm.snapshot.teachingStart }, + calendarTerm, + ); + const active = occurrences.find((occurrence) => occurrence.startUtc <= nowStamp && nowStamp < occurrence.endUtc); + if (active) { + return { + now: formatScheduleEntryLabel(active.entry, active.week, active.date, active.sourceWeekday), + }; + } + + const next = occurrences.find((occurrence) => occurrence.startUtc > nowStamp); + if (!next) return {}; + const dayOffset = Math.round( + (parseIsoDate(next.date).getTime() - parseIsoDate(clock.date).getTime()) / (24 * 60 * 60 * 1000), + ); + const detail = formatUpcomingScheduleEntryDetail( + next.entry, + next.week, + dayOffset, + next.date, + next.sourceWeekday, + ); + return { + next: formatScheduleEntryTitle(next.entry), + nextDetail: detail, + ...(dayOffset === 1 ? { tomorrowMorning: `${formatScheduleEntryTitle(next.entry)} — ${detail}` } : {}), + }; +} + export function nearestUpcomingExam( exams: readonly ExamRecord[], options: { now: Date }, @@ -407,13 +558,14 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow const wallClockDate = new Date(Date.UTC(year, month - 1, day)); const weekday = wallClockDate.getUTCDay() === 0 ? 7 : wallClockDate.getUTCDay(); const minutesSinceMidnight = hour * 60 + minute; - for (const [periodText, [startHour, startMinute]] of Object.entries(PERIOD_START_TIMES)) { + const date = `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`; + for (const [periodText, [startHour, startMinute]] of Object.entries(periodStartTimesForDate(date))) { const periodStart = Number(periodText); const startTotal = startHour * 60 + startMinute; const endTotal = startTotal + PERIOD_DURATION_MINUTES; if (minutesSinceMidnight < startTotal || minutesSinceMidnight >= endTotal) continue; return { - date: `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`, + date, time: `${partValue(parts, "hour")}:${partValue(parts, "minute")}`, weekday, periodStart, @@ -425,13 +577,13 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow } function periodStartUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]))); } function periodEndUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]) + PERIOD_DURATION_MINUTES * 60 * 1000)); } @@ -503,20 +655,34 @@ function formatScheduleEntryTitle(entry: PersonalScheduleEntry): string { return [entry.courseCode, entry.courseName || entry.description || entry.descriptionEn || "Unnamed course"].filter(Boolean).join(" ").trim(); } -function formatScheduleEntryLabel(entry: PersonalScheduleEntry, currentWeek: number): string { - return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0)}`; +function formatScheduleEntryLabel( + entry: PersonalScheduleEntry, + currentWeek: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0, date, sourceWeekday)}`; } -function formatUpcomingScheduleEntryDetail(entry: PersonalScheduleEntry, week: number, dayOffset: number): string { - const start = PERIOD_START_TIMES[entry.periodStart ?? 0]; - const end = PERIOD_START_TIMES[entry.periodEnd ?? 0]; +function formatUpcomingScheduleEntryDetail( + entry: PersonalScheduleEntry, + week: number, + dayOffset: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + const periods = periodStartTimesForDate(date); + const start = periods[entry.periodStart ?? 0]; + const end = periods[entry.periodEnd ?? 0]; const startText = start ? `${String(start[0]).padStart(2, "0")}:${String(start[1]).padStart(2, "0")}` : `P${entry.periodStart ?? "?"}`; const endTotal = end ? end[0] * 60 + end[1] + PERIOD_DURATION_MINUTES : undefined; const endText = endTotal === undefined ? `P${entry.periodEnd ?? "?"}` : formatPeriodEnd(endTotal); - const weekdayLabel = weekdayName(entry.day ?? 0); + const parsedDate = parseIsoDate(date); + const actualWeekday = parsedDate.getUTCDay() === 0 ? 7 : parsedDate.getUTCDay(); + const weekdayLabel = weekdayName(actualWeekday); const dayLabel = dayOffset === 0 ? "today" : dayOffset === 1 ? "tomorrow" : `week ${week} ${weekdayLabel}`; return [ - dayLabel, + `${dayLabel}${sourceWeekday ? ` (makeup for ${sourceWeekday})` : ""}`, `${startText}-${endText}`, entry.room, entry.teacher, @@ -527,11 +693,15 @@ function weekdayName(day: number): string { return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][day - 1] ?? `day ${day}`; } -function periodStartMinutes(period: number): number { - const slot = PERIOD_START_TIMES[period]; +function periodStartMinutes(period: number, date: string): number { + const slot = periodStartTimesForDate(date)[period]; return slot ? slot[0] * 60 + slot[1] : Number.POSITIVE_INFINITY; } +function periodStartTimesForDate(date: string): Readonly> { + return date >= CURRENT_PERIOD_SCHEDULE_START ? PERIOD_START_TIMES : LEGACY_PERIOD_START_TIMES; +} + async function inspectIcsDestination(destination: string, overwrite: boolean): Promise<{ destination: string; existed: boolean }> { const absolute = resolvePath(destination); await assertPathAndParentsAreNotSymlinks(absolute); diff --git a/src/tis/remaining-selection.ts b/src/tis/remaining-selection.ts index acfa310..e3ccb0b 100644 --- a/src/tis/remaining-selection.ts +++ b/src/tis/remaining-selection.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { Semester } from "../core/semester.js"; import { CliError } from "../core/errors.js"; import { asRecord, numberValue, stringValue } from "./remaining-shared.js"; @@ -33,11 +34,13 @@ export interface SelectionContext { export interface SelectionPreviewInput { operation: SelectionOperation; courseId: string; + rwh?: string; round?: string; bid?: number; where?: SelectionBidWhere; ignoreConflicts?: boolean; ignoreZeroCapacity?: boolean; + clientRequestId?: string; } export interface SelectionVerificationStep { @@ -46,12 +49,32 @@ export interface SelectionVerificationStep { } export interface SelectionPreview { + clientRequestId: string; + exactTarget: { + courseId: string; + rwh?: string; + semester: string; + cultivation: "1" | "2"; + round: string; + bid: number; + where: SelectionBidWhere; + }; operation: SelectionOperation; endpoint: string; payload: Record; requiresExplicitConfirm: true; successHeuristic: string; verification: SelectionVerificationStep[]; + identifierContract: { + mutationInput: "courseId"; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; + }; + idempotency: { + upstreamKeySupported: false; + automaticRetry: "forbidden"; + note: string; + }; } export interface BidPlan { @@ -79,7 +102,6 @@ export interface SelectionObservedEntry { bid?: number; courseIdObserved: boolean; courseIdMatches: boolean; - raw: Record; } export interface SelectionStateObservation { @@ -104,6 +126,20 @@ export interface SelectionVerificationResult { observation: SelectionStateObservation; } +export interface SelectionReconciliationResult { + schemaVersion: "1"; + status: "applied" | "not_applied" | "still_uncertain"; + target: SelectionApplyTarget; + attempts: number; + observations: Array<{ + attempt: number; + state: "desired" | "inverse" | "conflicting"; + message: string; + }>; + automaticRetryAllowed: false; + message: string; +} + export interface BidProjection { previousTotalBid: number; totalBid: number; @@ -138,12 +174,32 @@ export function buildSelectionPreview(context: SelectionContext, input: Selectio const endpoint = selectionEndpoint(input.operation, input.where); const payload = buildSelectionPayload(context, input); return { + clientRequestId: input.clientRequestId?.trim() || randomUUID(), + exactTarget: { + courseId: input.courseId, + ...(input.rwh?.trim() ? { rwh: input.rwh.trim() } : {}), + semester: context.semester.value, + cultivation: context.cultivation, + round: stringValue(payload.p_xkfsdm), + bid: input.bid ?? 1, + where: input.where ?? "enrolled", + }, operation: input.operation, endpoint, payload, requiresExplicitConfirm: true, successHeuristic: successHeuristic(input.operation, input.where), verification: verificationSteps(input.operation, input.where), + identifierContract: { + mutationInput: "courseId", + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + }, + idempotency: { + upstreamKeySupported: false, + automaticRetry: "forbidden", + note: "The client request ID is correlation metadata only and is not sent as an upstream idempotency key.", + }, }; } @@ -240,6 +296,7 @@ export function planBidUpdates( .map((pick) => buildSelectionPreview(context, { operation: "bid.update", courseId: pick.courseId, + ...(pick.rwh ? { rwh:pick.rwh } : {}), round: options.round, bid: pick.bid, where: options.where, @@ -353,6 +410,9 @@ export function verifySelectionWrite( target: SelectionApplyTarget, ): SelectionVerificationResult { const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { status: "not_observed", message: "Read-back did not establish the requested selection round.", observation }; + } if (target.operation === "cart.add") { const cart = observation.cart; if (!cart) return { status: "not_observed", message: "The exact RWH was not observed in cart after the write.", observation }; @@ -400,6 +460,73 @@ export function verifySelectionWrite( return { status: "not_observed", message: "Unsupported selection operation.", observation }; } +export function reconcileSelectionSnapshots( + states: readonly SelectionStateSnapshot[], + target: SelectionApplyTarget, +): SelectionReconciliationResult { + const observations = states.map((state, index) => ({ + attempt: index + 1, + ...classifyReconciliationState(state, target), + })); + const hasConflict = observations.some((observation) => observation.state === "conflicting"); + const desired = observations.filter((observation) => observation.state === "desired").length; + const inverse = observations.filter((observation) => observation.state === "inverse").length; + const finalState = observations.at(-1)?.state; + const status: SelectionReconciliationResult["status"] = hasConflict + ? "still_uncertain" + : finalState === "desired" + ? "applied" + : desired === 0 && inverse >= 2 + ? "not_applied" + : "still_uncertain"; + return { + schemaVersion: "1", + status, + target, + attempts: states.length, + observations, + automaticRetryAllowed: false, + message: status === "applied" + ? "The exact target reached the requested final state during bounded read-back." + : status === "not_applied" + ? "Two or more consistent exact read-backs retained the pre-mutation state. Review before issuing any new mutation." + : "Read-back was missing, changed across attempts, or conflicted on exact identity; do not retry automatically.", + }; +} + +function classifyReconciliationState( + state: SelectionStateSnapshot, + target: SelectionApplyTarget, +): { state: "desired" | "inverse" | "conflicting"; message: string } { + const observation = observeSelectionState(state, target); + if (!observation.roundCode || observation.roundCode !== target.round) { + return { state: "conflicting", message: "Read-back did not establish the requested selection round." }; + } + const candidates = [observation.cart, observation.enrolled].filter((entry) => entry !== undefined); + if (candidates.some((entry) => entry.courseIdObserved && !entry.courseIdMatches)) { + return { state: "conflicting", message: "The RWH was observed with a different course ID." }; + } + const verification = verifySelectionWrite(state, target); + if (verification.status === "confirmed") return { state: "desired", message: verification.message }; + + const exact = target.operation === "cart.add" + ? observation.cart + : target.operation === "enroll" || target.operation === "drop" + ? observation.enrolled + : target.operation === "cart.remove" + ? observation.cart + : target.where === "cart" + ? observation.cart + : observation.enrolled; + if (target.operation === "bid.update" && exact?.bid === undefined) { + return { state: "conflicting", message: "Read-back omitted the bid value needed to establish the final state." }; + } + if (exact?.courseIdMatches || (!exact && (target.operation === "cart.add" || target.operation === "enroll"))) { + return { state: "inverse", message: verification.message }; + } + return { state: "conflicting", message: verification.message }; +} + export function projectBidTotal( state: SelectionStateSnapshot, picks: readonly BidPick[], @@ -534,7 +661,6 @@ function observedEntry( bid: numberValue(item.xkxs ?? item.XKXS), courseIdObserved: courseId !== undefined, courseIdMatches: courseId !== undefined && courseId === target.courseId, - raw: item, }; } return undefined; diff --git a/src/tis/remaining-text.ts b/src/tis/remaining-text.ts index 01885eb..ddb0a74 100644 --- a/src/tis/remaining-text.ts +++ b/src/tis/remaining-text.ts @@ -97,6 +97,8 @@ export function formatSelectionPreview( return [ `TIS selection preview · ${preview.operation}`, "No network request or mutation was performed.", + `Client request ID: ${preview.clientRequestId}`, + "Upstream idempotency key: unsupported; automatic retry forbidden.", ...(options.exactTarget ? [ "Exact target", diff --git a/src/tis/selection-bundles.ts b/src/tis/selection-bundles.ts new file mode 100644 index 0000000..dc4a523 --- /dev/null +++ b/src/tis/selection-bundles.ts @@ -0,0 +1,207 @@ +import type { Course, CourseComponentType, ScheduleSlot } from "./types.js"; + +export interface SelectionIdentifierTarget { + componentId: string; + taskId: string; + mutationCourseId?: string; + mutationPayloadField: "p_id"; + readbackIdentity: readonly ["courseId", "rwh"]; +} + +export interface SelectionCourseComponent { + componentId: string; + type: CourseComponentType; + required: boolean; + creditBearing: boolean; + taskId: string; + mutationCourseId?: string; + sectionName: string; + capacity?: number; + enrolled?: number; + teachingTeam: string[]; + meetings: ScheduleSlot[]; +} + +export interface SelectionCourseBundle { + schemaVersion: "1"; + bundleId: string; + courseCode: string; + courseName: string; + classGroup: string; + credits?: number; + creditStatus: "explicit" | "deduplicated" | "ambiguous"; + components: SelectionCourseComponent[]; + requiredComponentIds: string[]; + teachingTeam: string[]; + meetings: Array; + operationTargets: SelectionIdentifierTarget[]; + selectableWithoutGuessing: boolean; + warnings: string[]; +} + +export interface CourseDiagnosticRecord { + schemaVersion: "1"; + kind: "tis-selection-source-record"; + raw: Readonly>; +} + +/** Explicit diagnostics-only escape hatch. This envelope is never returned by a CLI command. */ +export function retainCourseSourceRecord(raw: Record): CourseDiagnosticRecord { + return { + schemaVersion: "1", + kind: "tis-selection-source-record", + raw: structuredClone(raw), + }; +} + +export function bundleSelectionCourses(courses: readonly Course[]): SelectionCourseBundle[] { + const groups = new Map(); + for (const course of courses) { + const bundleId = course.selection?.bundleId ?? `tis-task:${course.rwh}`; + const group = groups.get(bundleId) ?? []; + group.push(course); + groups.set(bundleId, group); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([bundleId, rows]) => buildBundle(bundleId, rows)); +} + +function buildBundle(bundleId: string, rows: readonly Course[]): SelectionCourseBundle { + const warnings: string[] = []; + const byComponent = new Map(); + let duplicateCreditConflict = false; + for (const course of [...rows].sort(componentOrder)) { + const componentId = course.selection?.componentId ?? course.rwh; + const existing = byComponent.get(componentId); + if (existing) { + if (existing.credits !== course.credits || existing.selection?.creditBearing !== course.selection?.creditBearing) { + duplicateCreditConflict = true; + } + warnings.push(`Duplicate source row for component ${componentId} was merged.`); + byComponent.set(componentId, { + ...existing, + teachers: unique([...existing.teachers, ...course.teachers]), + schedule: uniqueMeetings([...existing.schedule, ...course.schedule]), + }); + continue; + } + byComponent.set(componentId, course); + } + const ordered = [...byComponent.values()].sort(componentOrder); + const identities = new Set(rows.map((course) => `${course.code.trim().toUpperCase()}\u0000${course.name.trim()}`)); + if (identities.size > 1) warnings.push("Bundle source rows disagree on course identity; manual review is required."); + + const credit: ReturnType = duplicateCreditConflict + ? { status: "ambiguous" } + : resolveCreditCarrier(ordered); + if (credit.status === "ambiguous") warnings.push("Bundle source rows disagree on credits; no credit value was projected."); + const components = ordered.map((course, index): SelectionCourseComponent => ({ + componentId: course.selection?.componentId ?? course.rwh, + type: course.selection?.componentType ?? "unknown", + required: course.selection?.required ?? true, + creditBearing: credit.index === index, + taskId: course.rwh, + ...(course.selection?.identifiers.mutation?.value + ? { mutationCourseId: course.selection.identifiers.mutation.value } + : course.id + ? { mutationCourseId: course.id } + : {}), + sectionName: course.sectionName, + ...(course.capacity !== undefined ? { capacity:course.capacity } : {}), + ...(course.enrolled !== undefined ? { enrolled:course.enrolled } : {}), + teachingTeam: [...course.teachers], + meetings: [...course.schedule], + })); + const operationTargets = components.map((component): SelectionIdentifierTarget => ({ + componentId: component.componentId, + taskId: component.taskId, + ...(component.mutationCourseId ? { mutationCourseId: component.mutationCourseId } : {}), + mutationPayloadField: "p_id", + readbackIdentity: ["courseId", "rwh"], + })); + const requiredComponents = components.filter((component) => component.required); + const selectableWithoutGuessing = requiredComponents.length > 0 + && credit.status !== "ambiguous" + && identities.size === 1 + && requiredComponents.every((component) => Boolean(component.mutationCourseId && component.taskId)); + if (!requiredComponents.length || requiredComponents.some((component) => !component.mutationCourseId || !component.taskId)) { + warnings.push("At least one required component lacks an explicit mutation courseId/task rwh pair."); + } + + return { + schemaVersion: "1", + bundleId, + courseCode: ordered[0]?.code ?? "", + courseName: ordered[0]?.name ?? "", + classGroup: ordered[0]?.classGroup ?? "", + ...(credit.credits !== undefined ? { credits: credit.credits } : {}), + creditStatus: credit.status, + components, + requiredComponentIds: requiredComponents.map((component) => component.componentId), + teachingTeam: unique(components.flatMap((component) => component.teachingTeam)), + meetings: components.flatMap((component) => component.meetings.map((meeting) => ({ + ...meeting, + componentId: component.componentId, + componentType: component.type, + }))), + operationTargets, + selectableWithoutGuessing, + warnings, + }; +} + +function resolveCreditCarrier(courses: readonly Course[]): { + index?: number; + credits?: number; + status: SelectionCourseBundle["creditStatus"]; +} { + const explicit = courses + .map((course, index) => course.selection?.creditBearing === true ? index : undefined) + .filter((index): index is number => index !== undefined); + if (explicit.length === 1) return { index: explicit[0], credits: courses[explicit[0]]?.credits ?? 0, status: "explicit" }; + if (explicit.length > 1) return { status: "ambiguous" }; + + const positiveValues = uniqueNumbers(courses.map((course) => course.credits).filter((credits) => credits > 0)); + if (positiveValues.length > 1) return { status: "ambiguous" }; + const index = courses.findIndex((course) => course.credits === (positiveValues[0] ?? 0)); + return { + index: index >= 0 ? index : 0, + credits: positiveValues[0] ?? 0, + status: "deduplicated", + }; +} + +function componentOrder(left: Course, right: Course): number { + return componentRank(left.selection?.componentType ?? "unknown") + - componentRank(right.selection?.componentType ?? "unknown") + || left.rwh.localeCompare(right.rwh); +} + +function componentRank(type: CourseComponentType): number { + return ({ lecture: 0, lab: 1, tutorial: 2, other: 3, unknown: 4 })[type]; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function uniqueNumbers(values: readonly number[]): number[] { + return [...new Set(values)].sort((left, right) => left - right); +} + +function uniqueMeetings(values: readonly ScheduleSlot[]): ScheduleSlot[] { + const seen = new Set(); + return values.filter((meeting) => { + const key = JSON.stringify([ + meeting.weeks, + meeting.day, + meeting.periodStart, + meeting.periodEnd, + meeting.room, + ]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/tis/types.ts b/src/tis/types.ts index 1d5ebc7..ceee032 100644 --- a/src/tis/types.ts +++ b/src/tis/types.ts @@ -27,12 +27,27 @@ export interface Course { language: string; teachers: string[]; schedule: ScheduleSlot[]; + selection?: CourseSelectionContract; +} + +export type CourseComponentType = "lecture" | "lab" | "tutorial" | "other" | "unknown"; + +export interface CourseSelectionContract { + bundleId: string; + componentId: string; + componentType: CourseComponentType; + required: boolean; + creditBearing?: boolean; + identifiers: { + task: { field: "rwh"; value: string }; + mutation?: { field: "courseId"; payloadField: "p_id"; value: string }; + }; } export interface TisWriteResult { + clientRequestId: string; jg: string; message: string; - raw: Record; } export interface PersonalScheduleEntry {