diff --git a/go.mod b/go.mod index fe43bfc..ee8f493 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Life-USTC/CLI go 1.26.0 require ( - github.com/Life-USTC/client-core v0.5.0 + github.com/Life-USTC/client-core v0.6.0 github.com/PuerkitoBio/goquery v1.13.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 diff --git a/go.sum b/go.sum index 35ad7b8..44225ed 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Life-USTC/client-core v0.5.0 h1:TkSp/rP5V5rastpV8BtbTwR7IlwKh1b7++430Ccf3QQ= -github.com/Life-USTC/client-core v0.5.0/go.mod h1:2SgYKZ3hbEFEigNGPdbfFS2m149evVw2t/mEjA/S/LM= +github.com/Life-USTC/client-core v0.6.0 h1:SSr9J+RRm2DKZfcUrGZTmaarlaZ4akY/Ae5WtYkCHY8= +github.com/Life-USTC/client-core v0.6.0/go.mod h1:Cnvr4pdLI5T0waJ5oQevw35j+zd5NcdqZ52MMNqAn1k= github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM= github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= diff --git a/internal/school/browser.go b/internal/school/browser.go index 9c9a096..17e5be6 100644 --- a/internal/school/browser.go +++ b/internal/school/browser.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/Life-USTC/client-core/ustc" "github.com/PuerkitoBio/goquery" ) @@ -51,14 +52,14 @@ func newJWClient(ctx context.Context, creds Credentials) (*http.Client, error) { func newBlackboardClient(ctx context.Context, creds Credentials) (*http.Client, error) { return newAuthenticatedClientForTargets(ctx, creds, []loginTarget{ { - loginURL: bbNginxAuthLoginURL(bbCalendarPageURL), + loginURL: bbNginxAuthLoginURL(ustc.BlackboardCalendarPageURL), expectedHost: "www.bb.ustc.edu.cn", - postLoginURLs: []string{bbCalendarPageURL}, + postLoginURLs: []string{ustc.BlackboardCalendarPageURL}, }, { loginURL: bbLoginURL, expectedHost: "www.bb.ustc.edu.cn", - postLoginURLs: []string{bbCalendarPageURL, bbCalendarListURL}, + postLoginURLs: []string{ustc.BlackboardCalendarPageURL, ustc.BlackboardCalendarsURL}, }, }) } diff --git a/internal/school/client.go b/internal/school/client.go index afc45e9..aee7798 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -18,15 +18,8 @@ import ( ) const ( - catalogSemesterURL = "https://catalog.ustc.edu.cn/api/teach/semester/list" - catalogLessonURL = "https://catalog.ustc.edu.cn/api/teach/lesson/list-for-teach/%d?page=%d&pageSize=%d" - jwExamURL = "https://jw.ustc.edu.cn/for-std/exam-arrange" - jwScoreSemestersURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters" - jwScoreTypesURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeSheetTypes" - jwScoreListURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeList" - bbCalendarPageURL = "https://www.bb.ustc.edu.cn/webapps/calendar/viewPersonal" - bbCalendarListURL = "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/calendars?mode=personal&course_id=" - bbCalendarEventsURLBase = "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/events" + catalogSemesterURL = "https://catalog.ustc.edu.cn/api/teach/semester/list" + catalogLessonURL = "https://catalog.ustc.edu.cn/api/teach/lesson/list-for-teach/%d?page=%d&pageSize=%d" ) var errNoLessonIDs = errors.New("jw returned no lesson ids") @@ -35,12 +28,6 @@ func IsNoLessonData(err error) bool { return errors.Is(err, errNoLessonIDs) } -const ( - bbCalendarEventsStartMillis int64 = 0 - bbCalendarEventsEndMillis int64 = 4102444800000 - bbCalendarIDsPerRequestMax = 1800 -) - type Client struct { creds Credentials program Program @@ -231,12 +218,15 @@ func (c *Client) FetchExams(ctx context.Context, semesterIDs ...int) ([]ExamItem if err != nil { return nil, err } + return fetchJWExams(ctx, jwClient) +} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwExamURL, nil) +func fetchJWExams(ctx context.Context, client *http.Client) ([]ExamItem, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.ExamArrangementURL, nil) if err != nil { return nil, err } - res, err := jwClient.Do(req) + res, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch exams: %w", err) } @@ -244,12 +234,7 @@ func (c *Client) FetchExams(ctx context.Context, semesterIDs ...int) ([]ExamItem if res.StatusCode >= 300 { return nil, responseError(res) } - - items, err := parseExamsHTML(res.Body) - if err != nil { - return nil, err - } - return items, nil + return parseExamsHTML(res.Body) } func (c *Client) FetchScores(ctx context.Context) (ScoreReport, error) { @@ -282,6 +267,10 @@ func (c *Client) FetchHomework(ctx context.Context) ([]HomeworkItem, error) { return nil, err } + return fetchBlackboardHomework(ctx, bbClient) +} + +func fetchBlackboardHomework(ctx context.Context, bbClient *http.Client) ([]HomeworkItem, error) { var calendarIDs []string if err := withSchoolDebugStep("Blackboard fetch calendars", func() error { var err error @@ -304,8 +293,8 @@ func (c *Client) FetchHomework(ctx context.Context) ([]HomeworkItem, error) { statusesByCourse := map[string]map[string]string{} itemsByID := make(map[string]HomeworkItem) - for _, batch := range batchCalendarIDs(calendarIDs, bbCalendarIDsPerRequestMax) { - var events []blackboardCalendarEvent + for _, batch := range ustc.BatchBlackboardCalendarIDs(calendarIDs, ustc.BlackboardCalendarIDsPerRequestMax) { + var events []ustc.BlackboardCalendarEvent if err := withSchoolDebugStep(fmt.Sprintf("Blackboard fetch events calendars=%d", len(batch)), func() error { var err error events, err = fetchBlackboardCalendarEvents(ctx, bbClient, batch) @@ -315,7 +304,7 @@ func (c *Client) FetchHomework(ctx context.Context) ([]HomeworkItem, error) { } debugLog("Blackboard events=%d", len(events)) for _, event := range events { - lessonCode, semesterCode := splitBlackboardCalendarID(event.CalendarID) + lessonCode, semesterCode := ustc.SplitBlackboardCalendarID(event.CalendarID) status := event.EventType internalCourseID := courseIDs[event.CalendarID] if internalCourseID != "" && event.ItemSourceID != "" { @@ -356,27 +345,8 @@ func (c *Client) FetchHomework(ctx context.Context) ([]HomeworkItem, error) { return items, nil } -type blackboardCalendarPayload struct { - Calendars []blackboardCalendar `json:"calendars"` -} - -type blackboardCalendar struct { - ID string `json:"id"` -} - -type blackboardCalendarEvent struct { - ID string `json:"id"` - CalendarID string `json:"calendarId"` - ItemSourceID string `json:"itemSourceId"` - Title string `json:"title"` - Start string `json:"start"` - End string `json:"end"` - EventType string `json:"eventType"` - CalendarName string `json:"calendarName"` -} - func fetchBlackboardCourseCalendarIDs(ctx context.Context, client *http.Client) ([]string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, bbCalendarListURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.BlackboardCalendarsURL, nil) if err != nil { return nil, err } @@ -394,19 +364,19 @@ func fetchBlackboardCourseCalendarIDs(ctx context.Context, client *http.Client) if err != nil { return nil, fmt.Errorf("read Blackboard calendars: %w", err) } - var payload blackboardCalendarPayload - if err := json.Unmarshal(body, &payload); err != nil { + ids, err := ustc.ParseBlackboardCalendarIDs(body) + if err != nil { return nil, fmt.Errorf("decode Blackboard calendars: %w: %s", err, responseSnippet(body)) } - return filterBlackboardCourseCalendarIDs(payload.Calendars), nil + return ids, nil } -func fetchBlackboardCalendarEvents(ctx context.Context, client *http.Client, calendarIDs []string) ([]blackboardCalendarEvent, error) { +func fetchBlackboardCalendarEvents(ctx context.Context, client *http.Client, calendarIDs []string) ([]ustc.BlackboardCalendarEvent, error) { if len(calendarIDs) == 0 { return nil, nil } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildBlackboardCalendarEventsURL(calendarIDs), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.BlackboardCalendarEventsURL(calendarIDs), nil) if err != nil { return nil, err } @@ -420,15 +390,19 @@ func fetchBlackboardCalendarEvents(ctx context.Context, client *http.Client, cal return nil, responseError(res) } - var events []blackboardCalendarEvent - if err := json.NewDecoder(res.Body).Decode(&events); err != nil { + var body json.RawMessage + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode Blackboard homework: %w", err) + } + events, err := ustc.ParseBlackboardCalendarEvents(body) + if err != nil { return nil, fmt.Errorf("decode Blackboard homework: %w", err) } return events, nil } func fetchBlackboardCourseIDs(ctx context.Context, client *http.Client) (map[string]string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.bb.ustc.edu.cn/webapps/portal/execute/tabs/tabAction?tab_tab_group_id=_1_1", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.BlackboardCoursesURL, nil) if err != nil { return nil, err } @@ -442,34 +416,15 @@ func fetchBlackboardCourseIDs(ctx context.Context, client *http.Client) (map[str return nil, responseError(res) } - doc, err := goquery.NewDocumentFromReader(res.Body) + ids, err := ustc.ParseBlackboardCourseIDs(res.Body) if err != nil { return nil, fmt.Errorf("parse Blackboard portal courses: %w", err) } - out := map[string]string{} - doc.Find("a").Each(func(_ int, s *goquery.Selection) { - href, _ := s.Attr("href") - rawCourseID := strings.TrimSpace(strings.Split(strings.TrimSpace(s.Text()), ":")[0]) - if rawCourseID == "" { - return - } - parsed, err := url.Parse(strings.TrimSpace(href)) - if err != nil { - return - } - if parsed.Path != "/webapps/blackboard/execute/launcher" || parsed.Query().Get("type") != "Course" { - return - } - internalID := parsed.Query().Get("id") - if internalID != "" { - out[rawCourseID] = internalID - } - }) - return out, nil + return ids, nil } func fetchBlackboardGradeStatuses(ctx context.Context, client *http.Client, courseID string) (map[string]string, error) { - rawURL := "https://www.bb.ustc.edu.cn/webapps/gradebook/do/student/viewGrades?course_id=" + url.QueryEscape(courseID) + rawURL := ustc.BlackboardGradesURL(courseID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err @@ -484,103 +439,7 @@ func fetchBlackboardGradeStatuses(ctx context.Context, client *http.Client, cour return nil, responseError(res) } - return blackboardGradeStatusesFromHTML(res.Body), nil -} - -func blackboardGradeStatusesFromHTML(reader io.Reader) map[string]string { - doc, err := goquery.NewDocumentFromReader(reader) - if err != nil { - return nil - } - out := map[string]string{} - doc.Find("#grades_wrapper .sortable_item_row").Each(func(_ int, s *goquery.Selection) { - id := strings.TrimSpace(s.AttrOr("id", "")) - if id == "" { - return - } - status := strings.TrimSpace(s.Find(".cell.activity .activityType").First().Text()) - if status == "" { - status = strings.TrimSpace(s.Find(".cell.gradeStatus").First().Text()) - } - if status != "" { - out["_"+id+"_1"] = compactWhitespace(status) - } - }) - return out -} - -func splitBlackboardCalendarID(calendarID string) (string, string) { - parts := strings.Split(strings.TrimSpace(calendarID), ".") - if len(parts) < 3 { - return strings.TrimSpace(calendarID), "" - } - semesterCode := parts[len(parts)-1] - if len(semesterCode) != 6 { - return strings.TrimSpace(calendarID), "" - } - for _, r := range semesterCode[:4] { - if r < '0' || r > '9' { - return strings.TrimSpace(calendarID), "" - } - } - switch semesterCode[4:] { - case "SP", "SU", "FA": - return strings.Join(parts[:len(parts)-1], "."), semesterCode - default: - return strings.TrimSpace(calendarID), "" - } -} - -func batchCalendarIDs(calendarIDs []string, maxQueryLength int) [][]string { - if len(calendarIDs) == 0 { - return nil - } - if maxQueryLength <= 0 { - return [][]string{append([]string(nil), calendarIDs...)} - } - - batches := make([][]string, 0, 1) - current := make([]string, 0, len(calendarIDs)) - currentLen := 0 - for _, calendarID := range calendarIDs { - extraLen := len(calendarID) - if len(current) > 0 { - extraLen++ - } - if currentLen > 0 && currentLen+extraLen > maxQueryLength { - batches = append(batches, current) - current = make([]string, 0, len(calendarIDs)) - currentLen = 0 - } - current = append(current, calendarID) - currentLen += extraLen - } - if len(current) > 0 { - batches = append(batches, current) - } - return batches -} - -func buildBlackboardCalendarEventsURL(calendarIDs []string) string { - return fmt.Sprintf( - "%s?start=%d&end=%d&course_id=&calendarIds=%s", - bbCalendarEventsURLBase, - bbCalendarEventsStartMillis, - bbCalendarEventsEndMillis, - strings.Join(calendarIDs, ","), - ) -} - -func filterBlackboardCourseCalendarIDs(calendars []blackboardCalendar) []string { - ids := make([]string, 0, len(calendars)) - for _, calendar := range calendars { - if calendar.ID == "" || calendar.ID == "PERSONAL" || calendar.ID == "INSTITUTION" { - continue - } - ids = append(ids, calendar.ID) - } - slices.Sort(ids) - return slices.Compact(ids) + return ustc.ParseBlackboardGradeStatuses(res.Body), nil } func fetchStudentID(ctx context.Context, client *http.Client) (string, error) { @@ -695,28 +554,34 @@ func fetchJWScoreReport(ctx context.Context, client *http.Client) (ScoreReport, semesterIDs = append(semesterIDs, semester.ID) semesterNames[semester.ID] = semester.Name() } - - payload, err := fetchJWScoreList(ctx, client, types[0].ID, semesterIDs) + parsed, err := fetchJWScoreList(ctx, client, types[0].ID, semesterIDs, semesterNames) if err != nil { return ScoreReport{}, err } - summary, err := buildJWScoreSummary(payload.Overview, payload.StdGradeRank) - if err != nil { - return ScoreReport{}, err - } - - report := ScoreReport{ - Summary: summary, - Items: flattenJWScoreItems(payload.Semesters, semesterNames), + report := ScoreReport{Summary: parsed.Summary, Items: make([]ScoreItem, len(parsed.Items))} + for i, item := range parsed.Items { + // The numeric profile produces integer identifiers and a grade point, + // including zero for the CLI's existing missing-value behavior. + semesterID, _ := strconv.Atoi(item.SemesterID) + report.Items[i] = ScoreItem{ + SemesterID: semesterID, + SemesterName: item.SemesterName, + CourseName: item.CourseName, + LessonCode: item.LessonCode, + CourseCode: item.CourseCode, + Credits: item.Credits, + GradePoint: *item.GradePoint, + Score: item.Score, + GradeText: item.GradeText, + } } - SortScores(report.Items) return report, nil } func fetchJWScoreSemesters(ctx context.Context, client *http.Client) ([]Semester, error) { var semesters []Semester - if err := fetchJSON(ctx, client, jwScoreSemestersURL, nil, &semesters); err != nil { + if err := fetchJSON(ctx, client, ustc.ScoreSemestersURL, nil, &semesters); err != nil { return nil, fmt.Errorf("fetch score semesters: %w", err) } return semesters, nil @@ -724,73 +589,23 @@ func fetchJWScoreSemesters(ctx context.Context, client *http.Client) ([]Semester func fetchJWScoreTypes(ctx context.Context, client *http.Client) ([]scoreSheetType, error) { var types []scoreSheetType - if err := fetchJSON(ctx, client, jwScoreTypesURL, nil, &types); err != nil { + if err := fetchJSON(ctx, client, ustc.ScoreTypesURL, nil, &types); err != nil { return nil, fmt.Errorf("fetch score transcript types: %w", err) } return types, nil } -func fetchJWScoreList(ctx context.Context, client *http.Client, trainTypeID int, semesterIDs []int) (scoreListResponse, error) { - query := url.Values{} - query.Set("trainTypeId", strconv.Itoa(trainTypeID)) - query.Set("semesterIds", joinInts(semesterIDs)) - - var payload scoreListResponse - if err := fetchJSON(ctx, client, jwScoreListURL, query, &payload); err != nil { - return scoreListResponse{}, fmt.Errorf("fetch score list: %w", err) - } - return payload, nil -} - -func buildJWScoreSummary(overview scoreOverview, rank *scoreRankInfo) (json.RawMessage, error) { - summary := map[string]any{} - if overview.PassedCredits != 0 || overview.NotPassedCredits != 0 || overview.GPA != 0 || overview.WeightedScore != 0 || overview.ArithmeticScore != 0 { - summary["totalCredits"] = overview.PassedCredits + overview.NotPassedCredits - summary["earnedCredits"] = overview.PassedCredits - summary["failedCredits"] = overview.NotPassedCredits - summary["gpa"] = overview.GPA - summary["weightedAverage"] = overview.WeightedScore - summary["averageScore"] = overview.ArithmeticScore - } - if rank != nil { - summary["fromSemester"] = rank.StartSemesterName - summary["toSemester"] = rank.EndSemesterName - summary["periodGPA"] = rank.GPA - if rank.MajorName != "" && rank.MajorRank != 0 && rank.MajorStdCount != 0 { - summary["ranking"] = fmt.Sprintf("全校%s年级%s专业 GPA 排名 %d/%d", rank.Grade, rank.MajorName, rank.MajorRank, rank.MajorStdCount) - } - } - if len(summary) == 0 { - return nil, nil - } - return json.Marshal(summary) -} - -func flattenJWScoreItems(semesters []scoreSemesterScores, semesterNames map[int]string) []ScoreItem { - out := make([]ScoreItem, 0) - for _, semester := range semesters { - out = append(out, flattenScoreItems(semester.Scores, semester.ID, semesterNames[semester.ID])...) +func fetchJWScoreList(ctx context.Context, client *http.Client, trainTypeID int, semesterIDs []int, semesterNames map[int]string) (ustc.ScoreReport, error) { + rawURL := ustc.NumericScoreListURL(trainTypeID, semesterIDs) + var body json.RawMessage + if err := fetchJSON(ctx, client, rawURL, nil, &body); err != nil { + return ustc.ScoreReport{}, fmt.Errorf("fetch score list: %w", err) } - return out -} - -func flattenScoreItems(items []scoreAPIItem, defaultSemesterID int, defaultSemesterName string) []ScoreItem { - out := make([]ScoreItem, 0, len(items)) - for _, item := range items { - semesterID := firstNonZeroInt(item.SemesterID, item.SemesterAssoc, defaultSemesterID) - out = append(out, ScoreItem{ - SemesterID: semesterID, - SemesterName: firstNonEmpty(item.SemesterName, item.SemesterCh, defaultSemesterName, item.Semester.SemesterCn, item.Semester.SemesterEn), - CourseName: firstNonEmpty(item.CourseName, item.CourseNameCh, item.Course.NameZh, item.Course.NameEn, item.CourseNameEn), - LessonCode: item.LessonCode, - CourseCode: firstNonEmpty(item.CourseCode, item.Course.Code), - Credits: firstNonZero(item.Credits, item.Course.Credits), - GradePoint: item.GradePoint, - Score: firstNonEmpty(item.ScoreCh, item.Score, item.ScoreText, item.Grade, item.ScoreEn), - GradeText: firstNonEmpty(item.GradeText, item.ScoreText), - }) + report, err := ustc.ParseNumericScores(body, semesterNames) + if err != nil { + return ustc.ScoreReport{}, fmt.Errorf("fetch score list: decode %s: %w", rawURL, err) } - return out + return report, nil } func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, query url.Values, out *T) error { @@ -819,61 +634,27 @@ func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, q return nil } -func joinInts(values []int) string { - if len(values) == 0 { - return "" - } - parts := make([]string, len(values)) - for i, value := range values { - parts[i] = strconv.Itoa(value) - } - return strings.Join(parts, ",") -} - func parseExamsHTML(r io.Reader) ([]ExamItem, error) { - doc, err := goquery.NewDocumentFromReader(r) + body, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("parse exams page: %w", err) } - - items := make([]ExamItem, 0) - doc.Find("table tbody tr").Each(func(_ int, row *goquery.Selection) { - cells := row.Find("td") - if cells.Length() < 4 { - return - } - values := make([]string, 0, cells.Length()) - cells.Each(func(_ int, cell *goquery.Selection) { - values = append(values, compactWhitespace(cell.Text())) - }) - item := ExamItem{} - if len(values) > 0 { - item.CourseName = values[0] - } - if len(values) > 1 { - item.LessonCode = values[1] - } - if len(values) > 2 { - item.ExamType = values[2] - } - if len(values) > 3 { - item.DateTime = values[3] - } - if len(values) > 4 { - item.Location = values[4] - } - if len(values) > 5 { - item.Seat = values[5] - } - if len(values) > 6 { - item.Status = values[6] - } - if item.CourseName != "" { - items = append(items, item) + parsed, err := ustc.ParseExamStatusTable(body) + if err != nil { + return nil, err + } + items := make([]ExamItem, len(parsed)) + for i, item := range parsed { + items[i] = ExamItem{ + CourseName: item.CourseName, + LessonCode: item.LessonCode, + ExamType: item.ExamType, + DateTime: item.DateTime, + Location: item.Location, + Seat: item.Seat, + Status: item.Status, } - }) - - SortExams(items) + } return items, nil } @@ -1072,69 +853,10 @@ func (l catalogLesson) toCurriculumItem(semesterID int) CurriculumItem { } } -type scoreAPIItem struct { - SemesterID int `json:"semesterId"` - SemesterAssoc int `json:"semesterAssoc"` - SemesterName string `json:"semesterName"` - SemesterCh string `json:"semesterCh"` - SemesterEn string `json:"semesterEn"` - LessonCode string `json:"lessonCode"` - CourseCode string `json:"courseCode"` - CourseName string `json:"courseName"` - CourseNameCh string `json:"courseNameCh"` - CourseNameEn string `json:"courseNameEn"` - Credits float64 `json:"credits"` - GradePoint float64 `json:"gp"` - Score string `json:"score"` - ScoreCh string `json:"scoreCh"` - ScoreEn string `json:"scoreEn"` - ScoreText string `json:"scoreText"` - Grade string `json:"grade"` - GradeText string `json:"gradeDetail"` - Course scoreAPICourse `json:"course"` - Semester Semester `json:"semester"` -} - -type scoreAPICourse struct { - Code string `json:"code"` - NameZh string `json:"nameZh"` - NameEn string `json:"nameEn"` - Credits float64 `json:"credits"` -} - type scoreSheetType struct { ID int `json:"id"` } -type scoreListResponse struct { - Overview scoreOverview `json:"overview"` - Semesters []scoreSemesterScores `json:"semesters"` - StdGradeRank *scoreRankInfo `json:"stdGradeRank"` -} - -type scoreSemesterScores struct { - ID int `json:"id"` - Scores []scoreAPIItem `json:"scores"` -} - -type scoreOverview struct { - PassedCredits float64 `json:"passedCredits"` - GPA float64 `json:"gpa"` - WeightedScore float64 `json:"weightedScore"` - NotPassedCredits float64 `json:"notPassedCredits"` - ArithmeticScore float64 `json:"arithmeticScore"` -} - -type scoreRankInfo struct { - StartSemesterName string `json:"startSemesterName"` - EndSemesterName string `json:"endSemesterName"` - GPA float64 `json:"gpa"` - Grade string `json:"grade"` - MajorName string `json:"majorName"` - MajorRank int `json:"majorRank"` - MajorStdCount int `json:"majorStdCount"` -} - func responseError(res *http.Response) error { body, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) return fmt.Errorf("%s %s returned %s: %s", res.Request.Method, res.Request.URL, res.Status, strings.TrimSpace(string(body))) @@ -1160,12 +882,3 @@ func firstNonZero(values ...float64) float64 { } return 0 } - -func firstNonZeroInt(values ...int) int { - for _, value := range values { - if value != 0 { - return value - } - } - return 0 -} diff --git a/internal/school/client_test.go b/internal/school/client_test.go index 41c8cb9..8f8ef84 100644 --- a/internal/school/client_test.go +++ b/internal/school/client_test.go @@ -6,71 +6,6 @@ import ( "testing" ) -func TestFilterBlackboardCourseCalendarIDs(t *testing.T) { - t.Parallel() - - calendars := []blackboardCalendar{ - {ID: "PERSONAL"}, - {ID: "CS1001.01.2024FA"}, - {ID: "INSTITUTION"}, - {ID: "MATH2001.02.2024FA"}, - {ID: "CS1001.01.2024FA"}, - {ID: ""}, - } - - got := filterBlackboardCourseCalendarIDs(calendars) - want := []string{"CS1001.01.2024FA", "MATH2001.02.2024FA"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("filterBlackboardCourseCalendarIDs() = %v, want %v", got, want) - } -} - -func TestBatchCalendarIDs(t *testing.T) { - t.Parallel() - - calendarIDs := []string{"A123", "BC456", "DEF789", "GHIJ000"} - got := batchCalendarIDs(calendarIDs, 9) - want := [][]string{{"A123"}, {"BC456"}, {"DEF789"}, {"GHIJ000"}} - if !reflect.DeepEqual(got, want) { - t.Fatalf("batchCalendarIDs() = %v, want %v", got, want) - } -} - -func TestBuildBlackboardCalendarEventsURL(t *testing.T) { - t.Parallel() - - got := buildBlackboardCalendarEventsURL([]string{"CS1001.01.2024FA", "MATH2001.02.2024FA"}) - want := "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/events?start=0&end=4102444800000&course_id=&calendarIds=CS1001.01.2024FA,MATH2001.02.2024FA" - if got != want { - t.Fatalf("buildBlackboardCalendarEventsURL() = %q, want %q", got, want) - } -} - -func TestSplitBlackboardCalendarID(t *testing.T) { - t.Parallel() - - code, semester := splitBlackboardCalendarID("MATH3011.01.2023FA") - if code != "MATH3011.01" || semester != "2023FA" { - t.Fatalf("splitBlackboardCalendarID() = (%q, %q), want (%q, %q)", code, semester, "MATH3011.01", "2023FA") - } -} - -func TestFetchBlackboardGradeStatusesParsesRows(t *testing.T) { - t.Parallel() - - html := `
-
已评分
-
尚未评分
-
` - got := blackboardGradeStatusesFromHTML(strings.NewReader(html)) - if got["_77235_1"] != "已评分" { - t.Fatalf("status _77235_1 = %q, want 已评分", got["_77235_1"]) - } - if got["_59232_1"] != "尚未评分" { - t.Fatalf("status _59232_1 = %q, want 尚未评分", got["_59232_1"]) - } -} - func TestParseExamsHTMLEmptyTableReturnsEmptySlice(t *testing.T) { t.Parallel() diff --git a/internal/school/import_parsers_test.go b/internal/school/import_parsers_test.go new file mode 100644 index 0000000..785162f --- /dev/null +++ b/internal/school/import_parsers_test.go @@ -0,0 +1,411 @@ +package school + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +func importParserResponse(request *http.Request, status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + Request: request, + } +} + +func TestJWScoreImportRequestsAndCLIProjection(t *testing.T) { + t.Parallel() + const scoreResponse = `{ + "overview":{"passedCredits":10,"notPassedCredits":2,"gpa":3,"weightedScore":80,"arithmeticScore":81}, + "stdGradeRank":{"startSemesterName":"Start","endSemesterName":"End","gpa":2.5,"grade":"2024","majorName":"Math","majorRank":3,"majorStdCount":10}, + "semesters":[ + {"id":101,"scores":[{"courseName":" Zeta ","courseCode":" COURSE ","lessonCode":" 001 ","credits":0,"course":{"credits":4},"scoreCh":" A+ ","gradeDetail":" retake "}]}, + {"id":303,"scores":[ + {"semesterId":505,"semesterAssoc":404,"semesterName":" Override ","courseName":" Beta ","courseNameCh":"ignored","lessonCode":"B.01","credits":3,"course":{"credits":8},"gp":4.3,"scoreCh":" ","score":" 95 ","scoreText":" Details "}, + {"semesterAssoc":404,"semesterCh":" Display ","courseNameCh":" Alpha ","course":{"code":" INNER ","nameZh":"ignored","credits":2},"gp":2.5,"scoreCh":" 80 "}, + {"semester":{"semesterCn":"ignored"},"course":{"nameZh":" Gamma ","code":" G ","credits":5},"scoreText":" Pass "} + ]} + ] + }` + var requests []string + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + if r.Method != http.MethodGet || r.Header.Get("Accept") != "application/json, text/plain, */*" || r.Header.Get("User-Agent") != schoolUserAgent { + t.Fatalf("unexpected score request: method=%s headers=%v", r.Method, r.Header) + } + var body string + switch r.URL.Path { + case "/for-std/grade/sheet/getSemesters": + body = `[{"id":101,"semesterCn":" 旧学期 "},{"id":303,"semesterEn":"Current"},{"id":202,"code":"CODE"}]` + case "/for-std/grade/sheet/getGradeSheetTypes": + body = `[{"id":7},{"id":9}]` + case "/for-std/grade/sheet/getGradeList": + body = scoreResponse + ` {"ignored":true}` + default: + t.Fatalf("unexpected score URL: %s", r.URL) + } + return importParserResponse(r, http.StatusOK, body), nil + })} + report, err := fetchJWScoreReport(context.Background(), client) + if err != nil { + t.Fatal(err) + } + wantRequests := []string{ + "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters", + "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeSheetTypes", + "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeList?semesterIds=101%2C303%2C202&trainTypeId=7", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("score requests = %v, want %v", requests, wantRequests) + } + wantItems := []ScoreItem{ + {SemesterID: 505, SemesterName: "Override", CourseName: "Beta", LessonCode: "B.01", Credits: 3, GradePoint: 4.3, Score: "95", GradeText: "Details"}, + {SemesterID: 404, SemesterName: "Display", CourseName: "Alpha", CourseCode: "INNER", Credits: 2, GradePoint: 2.5, Score: "80"}, + {SemesterID: 303, SemesterName: "Current", CourseName: "Gamma", CourseCode: "G", Credits: 5, Score: "Pass", GradeText: "Pass"}, + {SemesterID: 101, SemesterName: "旧学期", CourseName: "Zeta", LessonCode: " 001 ", CourseCode: "COURSE", Credits: 4, Score: "A+", GradeText: "retake"}, + } + if !reflect.DeepEqual(report.Items, wantItems) { + t.Fatalf("score items = %#v, want %#v", report.Items, wantItems) + } + var summary map[string]any + if err := json.Unmarshal(report.Summary, &summary); err != nil { + t.Fatal(err) + } + wantSummary := map[string]any{ + "totalCredits": float64(12), "earnedCredits": float64(10), "failedCredits": float64(2), + "gpa": float64(3), "weightedAverage": float64(80), "averageScore": float64(81), + "fromSemester": "Start", "toSemester": "End", "periodGPA": float64(2.5), + "ranking": "全校2024年级Math专业 GPA 排名 3/10", + } + if !reflect.DeepEqual(summary, wantSummary) { + t.Fatalf("score summary = %#v, want %#v", summary, wantSummary) + } + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var output map[string]json.RawMessage + if err := json.Unmarshal(encoded, &output); err != nil { + t.Fatal(err) + } + if len(output) != 2 || output["summary"] == nil || output["items"] == nil { + t.Fatalf("CLI report fields changed: %s", encoded) + } + var items []map[string]any + if err := json.Unmarshal(output["items"], &items); err != nil { + t.Fatal(err) + } + if items[0]["semesterId"] != float64(505) || items[0]["gradePoint"] != 4.3 { + t.Fatalf("CLI numeric fields changed: %s", encoded) + } + if _, present := items[2]["gradePoint"]; present { + t.Fatalf("zero grade point must retain omitempty: %s", encoded) + } +} + +func TestJWScoreImportEmptySelectionsAndSummary(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, semesters, types, body, wantError, summary string + requestCount int + }{ + {name: "no semesters", semesters: `[]`, wantError: "jw returned no score semesters", requestCount: 1}, + {name: "no transcript types", semesters: `[{"id":301}]`, types: `[]`, wantError: "jw returned no score transcript types", requestCount: 2}, + {name: "empty scores", semesters: `[{"id":301}]`, types: `[{"id":7}]`, body: `{}`, requestCount: 3}, + {name: "null scores", semesters: `[{"id":301}]`, types: `[{"id":7}]`, body: `null`, requestCount: 3}, + {name: "zero overview", semesters: `[{"id":301}]`, types: `[{"id":7}]`, body: `{"overview":{"passedCredits":0,"gpa":0}}`, requestCount: 3}, + {name: "empty rank remains present", semesters: `[{"id":301}]`, types: `[{"id":7}]`, body: `{"stdGradeRank":{}}`, summary: `{"fromSemester":"","periodGPA":0,"toSemester":""}`, requestCount: 3}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests++ + var body string + switch r.URL.Path { + case "/for-std/grade/sheet/getSemesters": + body = tc.semesters + case "/for-std/grade/sheet/getGradeSheetTypes": + body = tc.types + case "/for-std/grade/sheet/getGradeList": + body = tc.body + default: + t.Fatalf("unexpected score URL: %s", r.URL) + } + return importParserResponse(r, http.StatusOK, body), nil + })} + report, err := fetchJWScoreReport(context.Background(), client) + if requests != tc.requestCount { + t.Fatalf("score request count = %d, want %d", requests, tc.requestCount) + } + if tc.wantError != "" { + if err == nil || err.Error() != tc.wantError || !reflect.DeepEqual(report, ScoreReport{}) { + t.Fatalf("score result = %#v, %v; want empty report, %q", report, err, tc.wantError) + } + return + } + if err != nil || !reflect.DeepEqual(report.Items, []ScoreItem{}) || string(report.Summary) != tc.summary { + t.Fatalf("score result = %#v, %v; want empty items and summary %q", report, err, tc.summary) + } + if tc.summary == "" { + encoded, err := json.Marshal(report) + if err != nil || string(encoded) != `{"items":[]}` { + t.Fatalf("empty CLI score report = %s, %v", encoded, err) + } + } + }) + } +} + +func TestJWExamImportHTTPProjectionAndEmptyTable(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, body string + want []ExamItem + }{ + { + name: "status table fields and ordering", + body: ` + + + + +
ZetaZ.01 Final 2026-12-01 09:00 Room 2 18 Pending ignored detail
Math Theory M.01Midterm2026-11-01 09:00Room 127Ready
AlphaA.01Quiz2026-11-01 09:00
ignoredQuizearlier
`, + want: []ExamItem{ + {CourseName: "Alpha", LessonCode: "A.01", ExamType: "Quiz", DateTime: "2026-11-01 09:00"}, + {CourseName: "Math Theory", LessonCode: "M.01", ExamType: "Midterm", DateTime: "2026-11-01 09:00", Location: "Room 1", Seat: "27", Status: "Ready"}, + {CourseName: "Zeta", LessonCode: "Z.01", ExamType: "Final", DateTime: "2026-12-01 09:00", Location: "Room 2", Seat: "18", Status: "Pending"}, + }, + }, + {name: "empty table", body: `
`, want: []ExamItem{}}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests++ + if r.Method != http.MethodGet || r.URL.String() != "https://jw.ustc.edu.cn/for-std/exam-arrange" { + t.Fatalf("unexpected exam request: %s %s", r.Method, r.URL) + } + return importParserResponse(r, http.StatusOK, tc.body), nil + })} + got, err := fetchJWExams(context.Background(), client) + if err != nil || requests != 1 || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("exams = %#v, %v; requests=%d, want %#v", got, err, requests, tc.want) + } + }) + } +} + +func TestBlackboardHomeworkImportAssociationsAndOrdering(t *testing.T) { + t.Parallel() + var requests []string + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + if r.Method != http.MethodGet { + t.Fatalf("unexpected Blackboard method: %s", r.Method) + } + var body, accept string + switch r.URL.Path { + case "/webapps/calendar/calendarData/calendars": + accept = "application/json" + body = `{"calendars":[{"id":"MATH.01.2026FA"},{"id":"PERSONAL"},{"id":"CS.01.2026FA"},{"id":"MATH.01.2026FA"},{"id":"INSTITUTION"}]}` + case "/webapps/portal/execute/tabs/tabAction": + accept = "text/html" + body = `CS.01.2026FA: ComputingMATH.01.2026FA: Math` + case "/webapps/calendar/calendarData/events": + accept = "application/json" + body = `[ + {"id":"later","calendarId":"CS.01.2026FA","itemSourceId":"_10_1","title":"Late assignment","calendarName":"Computing","start":" raw start ","end":"2026-12-01T00:00:00Z","eventType":"Assignment"}, + {"id":"early","calendarId":"MATH.01.2026FA","itemSourceId":"_20_1","title":"Early assignment","calendarName":"Zeta","start":"2026-10-20T08:00:00+08:00","end":"2026-11-01T00:00:00Z","eventType":"Not started"}, + {"id":"duplicate","calendarId":"CS.01.2026FA","title":"Old version","calendarName":"Discarded","start":"old","end":"old","eventType":"Old"}, + {"id":"duplicate","calendarId":"MATH.01.2026FA","title":"Final version","calendarName":"Alpha","start":" 2026-10-21T08:00:00+08:00 ","end":"2026-11-01T00:00:00Z","eventType":"New"} + ] {"ignored":true}` + case "/webapps/gradebook/do/student/viewGrades": + accept = "text/html" + body = `
Needs + Grading
ignored fallback
` + default: + t.Fatalf("unexpected Blackboard URL: %s", r.URL) + } + if r.Header.Get("Accept") != accept { + t.Fatalf("Blackboard Accept = %q, want %q", r.Header.Get("Accept"), accept) + } + return importParserResponse(r, http.StatusOK, body), nil + })} + items, err := fetchBlackboardHomework(context.Background(), client) + if err != nil { + t.Fatal(err) + } + wantRequests := []string{ + "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/calendars?mode=personal&course_id=", + "https://www.bb.ustc.edu.cn/webapps/portal/execute/tabs/tabAction?tab_tab_group_id=_1_1", + "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/events?start=0&end=4102444800000&course_id=&calendarIds=CS.01.2026FA,MATH.01.2026FA", + "https://www.bb.ustc.edu.cn/webapps/gradebook/do/student/viewGrades?course_id=_123_1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("Blackboard requests = %v, want %v", requests, wantRequests) + } + wantItems := []HomeworkItem{ + {ID: "duplicate", Title: "Final version", CourseName: "Alpha", LessonCode: "MATH.01", SemesterCode: "2026FA", StartAt: " 2026-10-21T08:00:00+08:00 ", EndAt: "2026-11-01T00:00:00Z", Status: "New"}, + {ID: "early", Title: "Early assignment", CourseName: "Zeta", LessonCode: "MATH.01", SemesterCode: "2026FA", ExternalItemID: "_20_1", StartAt: "2026-10-20T08:00:00+08:00", EndAt: "2026-11-01T00:00:00Z", Status: "Not started"}, + {ID: "later", Title: "Late assignment", CourseName: "Computing", LessonCode: "CS.01", SemesterCode: "2026FA", ExternalItemID: "_10_1", StartAt: " raw start ", EndAt: "2026-12-01T00:00:00Z", Status: "Needs Grading"}, + } + if !reflect.DeepEqual(items, wantItems) { + t.Fatalf("Blackboard homework = %#v, want %#v", items, wantItems) + } +} + +func TestBlackboardHomeworkEmptyCalendarImport(t *testing.T) { + t.Parallel() + var paths []string + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/webapps/calendar/calendarData/calendars": + return importParserResponse(r, http.StatusOK, `{"calendars":[]}`), nil + case "/webapps/portal/execute/tabs/tabAction": + return importParserResponse(r, http.StatusOK, ""), nil + default: + t.Fatalf("empty calendar import requested %s", r.URL) + return nil, nil + } + })} + items, err := fetchBlackboardHomework(context.Background(), client) + wantPaths := []string{"/webapps/calendar/calendarData/calendars", "/webapps/portal/execute/tabs/tabAction"} + if err != nil || !reflect.DeepEqual(items, []HomeworkItem{}) || !reflect.DeepEqual(paths, wantPaths) { + t.Fatalf("empty Blackboard import = %#v, %v; paths=%v", items, err, paths) + } +} + +type importParserReadError struct { + err error +} + +func (reader importParserReadError) Read([]byte) (int, error) { return 0, reader.err } + +func TestSchoolImportTransportAndDecodeFailures(t *testing.T) { + t.Parallel() + responses := map[string]string{ + "/for-std/grade/sheet/getSemesters": `[{"id":301}]`, + "/for-std/grade/sheet/getGradeSheetTypes": `[{"id":7}]`, + "/for-std/grade/sheet/getGradeList": `{}`, + "/for-std/exam-arrange": `
`, + "/webapps/calendar/calendarData/calendars": `{"calendars":[{"id":"CS.01.2026FA"}]}`, + "/webapps/portal/execute/tabs/tabAction": `CS.01.2026FA: Computing`, + "/webapps/calendar/calendarData/events": `[{"id":"item","calendarId":"CS.01.2026FA","itemSourceId":"_10_1","eventType":"Assignment"}]`, + "/webapps/gradebook/do/student/viewGrades": `
`, + } + scores := func(client *http.Client) error { + _, err := fetchJWScoreReport(context.Background(), client) + return err + } + exams := func(client *http.Client) error { + _, err := fetchJWExams(context.Background(), client) + return err + } + homework := func(client *http.Client) error { + _, err := fetchBlackboardHomework(context.Background(), client) + return err + } + for _, endpoint := range []struct { + name, path, networkPrefix, readPrefix, decodePrefix, wrongType string + fetch func(*http.Client) error + }{ + {name: "score list", path: "/for-std/grade/sheet/getGradeList", networkPrefix: "fetch score list:", readPrefix: "fetch score list: decode https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeList?semesterIds=301&trainTypeId=7:", decodePrefix: "fetch score list: decode https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeList?semesterIds=301&trainTypeId=7:", wrongType: `{"semesters":[{"scores":[{"credits":"bad"}]}]}`, fetch: scores}, + {name: "exams", path: "/for-std/exam-arrange", networkPrefix: "fetch exams:", readPrefix: "parse exams page:", fetch: exams}, + {name: "Blackboard calendars", path: "/webapps/calendar/calendarData/calendars", networkPrefix: "fetch Blackboard calendars:", readPrefix: "read Blackboard calendars:", decodePrefix: "decode Blackboard calendars:", wrongType: `{"calendars":[{"id":42}]}`, fetch: homework}, + {name: "Blackboard portal", path: "/webapps/portal/execute/tabs/tabAction", networkPrefix: "fetch Blackboard portal courses:", readPrefix: "parse Blackboard portal courses:", fetch: homework}, + {name: "Blackboard events", path: "/webapps/calendar/calendarData/events", networkPrefix: "fetch Blackboard homework:", readPrefix: "decode Blackboard homework:", decodePrefix: "decode Blackboard homework:", wrongType: `[{"title":true}]`, fetch: homework}, + {name: "Blackboard grades", path: "/webapps/gradebook/do/student/viewGrades", networkPrefix: "fetch Blackboard grades:", fetch: homework}, + } { + failures := []string{"network", "HTTP status"} + if endpoint.readPrefix != "" { + failures = append(failures, "body read") + } + if endpoint.decodePrefix != "" { + failures = append(failures, "malformed JSON", "wrong JSON type") + } + for _, failure := range failures { + t.Run(endpoint.name+"/"+failure, func(t *testing.T) { + sentinel := errors.New("import fixture unavailable") + targetURL := "" + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + body, exists := responses[r.URL.Path] + if !exists { + t.Fatalf("unexpected import URL: %s", r.URL) + } + if r.URL.Path != endpoint.path { + return importParserResponse(r, http.StatusOK, body), nil + } + targetURL = r.URL.String() + switch failure { + case "network": + return nil, sentinel + case "HTTP status": + return importParserResponse(r, http.StatusServiceUnavailable, "unavailable"), nil + case "body read": + response := importParserResponse(r, http.StatusOK, "") + response.Body = io.NopCloser(importParserReadError{err: sentinel}) + return response, nil + case "malformed JSON": + return importParserResponse(r, http.StatusOK, "{"), nil + case "wrong JSON type": + return importParserResponse(r, http.StatusOK, endpoint.wrongType), nil + default: + t.Fatalf("unknown failure: %s", failure) + return nil, nil + } + })} + err := endpoint.fetch(client) + if targetURL == "" || err == nil { + t.Fatalf("failure did not reach %s: target=%q err=%v", endpoint.path, targetURL, err) + } + var wantPrefix string + switch failure { + case "network": + wantPrefix = endpoint.networkPrefix + case "body read": + wantPrefix = endpoint.readPrefix + case "HTTP status": + want := "GET " + targetURL + " returned 503 Service Unavailable: unavailable" + if !strings.Contains(err.Error(), want) { + t.Fatalf("HTTP error = %v, want context %q", err, want) + } + case "malformed JSON", "wrong JSON type": + wantPrefix = endpoint.decodePrefix + } + if wantPrefix != "" && !strings.HasPrefix(err.Error(), wantPrefix) { + t.Fatalf("import error = %v, want prefix %q", err, wantPrefix) + } + if (failure == "network" || failure == "body read") && !errors.Is(err, sentinel) { + t.Fatalf("import error lost cause: %v", err) + } + }) + } + } +} + +func TestBlackboardGradeReadFailureRetainsEmptyStatus(t *testing.T) { + t.Parallel() + sentinel := errors.New("grade fixture read failed") + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://www.bb.ustc.edu.cn/webapps/gradebook/do/student/viewGrades?course_id=_123_1" || r.Header.Get("Accept") != "text/html" { + t.Fatalf("unexpected grade request: %s headers=%v", r.URL, r.Header) + } + response := importParserResponse(r, http.StatusOK, "") + response.Body = io.NopCloser(importParserReadError{err: sentinel}) + return response, nil + })} + statuses, err := fetchBlackboardGradeStatuses(context.Background(), client, "_123_1") + if err != nil || statuses != nil { + t.Fatalf("unreadable grade page = %#v, %v; want nil status map without error", statuses, err) + } +}