diff --git a/go.mod b/go.mod index ee8f493..8936e5b 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.6.0 + github.com/Life-USTC/client-core v0.7.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 44225ed..c80b2d5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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/Life-USTC/client-core v0.7.0 h1:h/yY3s8iW34GE/whQh/9NeTaTl2KzrwV9wFPmpMihBM= +github.com/Life-USTC/client-core v0.7.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/catalog_test.go b/internal/school/catalog_test.go new file mode 100644 index 0000000..f4e57e4 --- /dev/null +++ b/internal/school/catalog_test.go @@ -0,0 +1,278 @@ +package school + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +const catalogTestSemestersURL = "https://catalog.ustc.edu.cn/api/teach/semester/list" + +func TestCatalogSemesterRequestAndMetadataProjection(t *testing.T) { + t.Parallel() + body := `[{"id":301,"code":" code ","semesterCn":" 中文 ","semesterEn":" English ","startDate":"start","endDate":"end","isLast":true},{"id":101},{"id":301}] trailing` + want := []Semester{ + {ID: 301, Code: " code ", SemesterCn: " 中文 ", SemesterEn: " English ", StartDate: "start", EndDate: "end", IsLast: true}, + {ID: 101}, {ID: 301}, + } + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != catalogTestSemestersURL || r.Method != http.MethodGet || r.Header.Get("Accept") != "" || r.Header.Get("User-Agent") != "" { + t.Fatalf("catalog semester request changed: %s %s %v", r.Method, r.URL, r.Header) + } + return importParserResponse(r, http.StatusOK, body), nil + })} + got, err := fetchCatalogSemesters(context.Background(), client, func() (*http.Client, error) { t.Fatal("unexpected JW fallback"); return nil, nil }) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("semesters = %#v, %v; want %#v", got, err, want) + } + jw := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters" || r.Header.Get("Accept") != "application/json, text/plain, */*" || r.Header.Get("User-Agent") != schoolUserAgent { + t.Fatalf("JW metadata request changed: %s %v", r.URL, r.Header) + } + return importParserResponse(r, http.StatusOK, body), nil + })} + got, err = fetchJWScoreSemesters(context.Background(), jw) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("JW semesters = %#v, %v; want %#v", got, err, want) + } +} + +func TestCatalogSemesterFallbackRemainsLazy(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + status int + body string + authError bool + wantError string + wantCalls int + }{ + {name: "success", status: 200, body: `[]`, wantCalls: 1}, + {name: "invalid JSON is not a fallback", status: 200, body: `!`, wantError: "decode semesters:", wantCalls: 1}, + {name: "wrong shape is not a fallback", status: 200, body: `{}`, wantError: "decode semesters:", wantCalls: 1}, + {name: "HTTP fallback", status: 403, body: "catalog denied", wantCalls: 2}, + {name: "fallback login failure retains catalog error", status: 403, body: "catalog denied", authError: true, wantError: "GET " + catalogTestSemestersURL + " returned 403 Forbidden: catalog denied", wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + var requests []string + transport := courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + if r.URL.String() == catalogTestSemestersURL { + return importParserResponse(r, tc.status, tc.body), nil + } + if r.URL.String() != "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters" { + t.Fatalf("unexpected fallback URL %s", r.URL) + } + return importParserResponse(r, 200, `[{"id":17}]`), nil + }) + client := &http.Client{Transport: transport} + factories := 0 + items, err := fetchCatalogSemesters(context.Background(), client, func() (*http.Client, error) { + factories++ + if len(requests) != 1 { + t.Fatalf("fallback invoked before catalog request: %v", requests) + } + if tc.authError { + return nil, errors.New("login failed") + } + return client, nil + }) + if len(requests) != tc.wantCalls || (tc.status >= 300 && factories != 1) || (tc.status < 300 && factories != 0) { + t.Fatalf("fallback requests=%v factories=%d", requests, factories) + } + if tc.wantError != "" { + if err == nil || !strings.HasPrefix(err.Error(), tc.wantError) || items != nil { + t.Fatalf("fallback error = %#v, %v", items, err) + } + } else if err != nil { + t.Fatal(err) + } else if tc.status >= 300 && !reflect.DeepEqual(items, []Semester{{ID: 17}}) { + t.Fatalf("fallback items = %#v", items) + } + }) + } +} + +func TestCatalogAndScoreMetadataEmptyArrays(t *testing.T) { + t.Parallel() + for _, body := range []string{`null`, `[]`} { + t.Run(body, func(t *testing.T) { + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { return importParserResponse(r, 200, body), nil })} + catalog, err := fetchCatalogSemesters(context.Background(), client, func() (*http.Client, error) { t.Fatal("unexpected fallback"); return nil, nil }) + if err != nil { + t.Fatal(err) + } + semesters, err := fetchJWScoreSemesters(context.Background(), client) + if err != nil { + t.Fatal(err) + } + types, err := fetchJWScoreTypes(context.Background(), client) + if err != nil { + t.Fatal(err) + } + for _, value := range []any{catalog, semesters, types} { + encoded, _ := json.Marshal(value) + if string(encoded) != body { + t.Fatalf("metadata output = %s, want %s", encoded, body) + } + } + }) + } + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + return importParserResponse(r, 200, `[{"id":7},{"id":3},{"id":7}] {}`), nil + })} + types, err := fetchJWScoreTypes(context.Background(), client) + if err != nil || len(types) != 3 || types[0].ID != 7 || types[1].ID != 3 || types[2].ID != 7 { + t.Fatalf("transcript order = %#v, %v", types, err) + } +} + +func TestCatalogLessonPagesAndDirectResponses(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + pages []string + ids []int + nilResult bool + }{ + {"paged order and duplicates", []string{`{"rows":[{"id":3},{"id":1}],"totalPage":2}`, `{"rows":[{"id":3},{"id":2}],"totalPage":2}`}, []int{3, 1, 3, 2}, false}, + {"direct replaces previous page", []string{`{"rows":[{"id":9}],"totalPage":2}`, `[{"id":1}]`}, []int{1}, false}, + {"direct null replaces previous page", []string{`{"rows":[{"id":9}],"totalPage":2}`, `null`}, nil, true}, + {"direct empty", []string{`[]`}, nil, false}, + {"direct null", []string{`null`}, nil, true}, + {"paged empty", []string{`{"rows":[],"totalPage":0}`}, nil, true}, + {"missing total", []string{`{"rows":[{"id":5}]}`}, []int{5}, false}, + {"negative total", []string{`{"rows":[{"id":5}],"totalPage":-1}`}, []int{5}, false}, + {"empty page still advances", []string{`{"rows":[],"totalPage":2}`, `{"rows":[{"id":4}],"totalPage":2}`}, []int{4}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + calls++ + want := fmt.Sprintf("https://catalog.ustc.edu.cn/api/teach/lesson/list-for-teach/301?page=%d&pageSize=500", calls) + if r.Method != http.MethodGet || r.URL.String() != want || calls > len(tc.pages) { + t.Fatalf("page request = %s %s, want %s", r.Method, r.URL, want) + } + return importParserResponse(r, 200, tc.pages[calls-1]), nil + })} + lessons, err := fetchCatalogLessons(context.Background(), client, 301) + if err != nil || calls != len(tc.pages) || (lessons == nil) != tc.nilResult { + t.Fatalf("pages = %#v, %v, calls=%d", lessons, err, calls) + } + var ids []int + for _, lesson := range lessons { + ids = append(ids, lesson.ID) + } + if !reflect.DeepEqual(ids, tc.ids) { + t.Fatalf("lesson IDs = %v, want %v", ids, tc.ids) + } + }) + } +} + +func TestCatalogLessonFieldsReachCLIProjection(t *testing.T) { + t.Parallel() + body := `[{"id":42,"code":" A.01 ","courseCode":"OUT","courseName":"Outer","credits":4,"dateTimePlacePersonText":{"cn":" 中文安排 ","en":"English"},"course":{"code":"INNER","nameZh":"中文","nameEn":"English","cn":"CN","en":"EN","credits":2},"teacherAssignmentList":[{"cn":" C ","en":" E ","teacher":{"nameZh":" 中 ","nameEn":" English "}},{"cn":"C"}]},{"id":5,"course":{"code":"INNER","nameEn":"English","credits":2},"dateTimePlacePersonText":" raw schedule ","teacherAssignmentList":[{"en":"EN"}]}]` + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { return importParserResponse(r, 200, body), nil })} + lessons, err := fetchCatalogLessons(context.Background(), client, 301) + if err != nil || len(lessons) != 2 { + t.Fatalf("lessons = %#v, %v", lessons, err) + } + want := []CurriculumItem{ + {SemesterID: 301, LessonID: 42, LessonCode: " A.01 ", CourseCode: "OUT", CourseName: "Outer", Credits: 4, Schedule: "中文安排", Teachers: []string{"中", "C"}}, + {SemesterID: 301, LessonID: 5, CourseCode: "INNER", CourseName: "English", Credits: 2, Schedule: "raw schedule", Teachers: []string{"EN"}}, + } + for i, lesson := range lessons { + if got := lesson.toCurriculumItem(301); !reflect.DeepEqual(got, want[i]) { + t.Fatalf("CLI lesson = %#v, want %#v", got, want[i]) + } + } + if lessons[0].Course.CN != "CN" || lessons[0].TeacherAssignmentList[0].EN != " E " || lessons[0].TeacherAssignmentList[0].Teacher.NameEn != " English " { + t.Fatalf("nested fields lost: %#v", lessons[0]) + } +} + +func TestCatalogAndMetadataFailuresKeepContext(t *testing.T) { + t.Parallel() + for _, endpoint := range []struct { + name, prefix, url string + fetch func(*http.Client) error + catalogPage bool + }{ + {"catalog semesters", "decode semesters: ", catalogTestSemestersURL, func(c *http.Client) error { + _, err := fetchCatalogSemesters(context.Background(), c, func() (*http.Client, error) { t.Fatal("unexpected fallback"); return nil, nil }) + return err + }, false}, + {"score semesters", "fetch score semesters: decode https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters: ", "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters", func(c *http.Client) error { _, err := fetchJWScoreSemesters(context.Background(), c); return err }, false}, + {"score types", "fetch score transcript types: decode https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeSheetTypes: ", "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeSheetTypes", func(c *http.Client) error { _, err := fetchJWScoreTypes(context.Background(), c); return err }, false}, + {"catalog page", "decode catalog lessons: ", "https://catalog.ustc.edu.cn/api/teach/lesson/list-for-teach/301?page=2&pageSize=500", func(c *http.Client) error { + rows, err := fetchCatalogLessons(context.Background(), c, 301) + if err != nil && rows != nil { + t.Errorf("returned partial lessons on failure: %#v", rows) + } + return err + }, true}, + } { + failures := []string{"network", "read", "syntax", "type"} + if endpoint.catalogPage { + failures = append(failures, "HTTP", "trailing document") + } + for _, failure := range failures { + t.Run(endpoint.name+"/"+failure, func(t *testing.T) { + calls := 0 + sentinel := errors.New("catalog fixture failure") + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + calls++ + if endpoint.catalogPage && calls == 1 { + return importParserResponse(r, 200, `{"rows":[{"id":7}],"totalPage":2}`), nil + } + if r.URL.String() != endpoint.url { + t.Fatalf("failure URL = %s, want %s", r.URL, endpoint.url) + } + if failure == "network" { + return nil, sentinel + } + response := importParserResponse(r, 200, `!`) + switch failure { + case "read": + response.Body = io.NopCloser(importParserReadError{err: sentinel}) + case "type": + response.Body = io.NopCloser(strings.NewReader(`[{"id":"7"}]`)) + case "HTTP": + response = importParserResponse(r, 503, " unavailable ") + case "trailing document": + response.Body = io.NopCloser(strings.NewReader(`[] {}`)) + } + return response, nil + })} + err := endpoint.fetch(client) + if err == nil { + t.Fatal("expected failure") + } + if endpoint.catalogPage && calls != 2 { + t.Fatalf("page calls = %d", calls) + } + switch failure { + case "network", "read": + if !errors.Is(err, sentinel) { + t.Fatalf("lost failure cause: %v", err) + } + case "syntax", "type", "trailing document": + if !strings.HasPrefix(err.Error(), endpoint.prefix) { + t.Fatalf("decode context = %v, want %s", err, endpoint.prefix) + } + case "HTTP": + if err.Error() != "GET "+endpoint.url+" returned 503 Service Unavailable: unavailable" { + t.Fatalf("HTTP context = %v", err) + } + } + }) + } + } +} diff --git a/internal/school/client.go b/internal/school/client.go index aee7798..fb849dc 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -17,11 +17,6 @@ import ( "github.com/PuerkitoBio/goquery" ) -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" -) - var errNoLessonIDs = errors.New("jw returned no lesson ids") func IsNoLessonData(err error) bool { @@ -32,7 +27,7 @@ type Client struct { creds Credentials program Program graduateScheduleClient *http.Client - graduateScheduleTerms []graduateYJS1Term + graduateScheduleTerms []ustc.YJS1Semester } func NewClient(creds Credentials, programs ...Program) *Client { @@ -52,7 +47,13 @@ func (c *Client) FetchSemesters(ctx context.Context) ([]Semester, error) { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, catalogSemesterURL, nil) + return fetchCatalogSemesters(ctx, client, func() (*http.Client, error) { + return newJWClient(ctx, c.creds) + }) +} + +func fetchCatalogSemesters(ctx context.Context, client *http.Client, fallback func() (*http.Client, error)) ([]Semester, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.CatalogSemestersURL, nil) if err != nil { return nil, err } @@ -63,18 +64,33 @@ func (c *Client) FetchSemesters(ctx context.Context) ([]Semester, error) { } defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { - jwClient, err := newJWClient(ctx, c.creds) + jwClient, err := fallback() if err != nil { return nil, responseError(res) } return fetchJWScoreSemesters(ctx, jwClient) } - var semesters []Semester - if err := json.NewDecoder(res.Body).Decode(&semesters); err != nil { + var data json.RawMessage + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { return nil, fmt.Errorf("decode semesters: %w", err) } - return semesters, nil + parsed, err := ustc.ParseUndergraduateSemesters(data) + if err != nil { + return nil, fmt.Errorf("decode semesters: %w", err) + } + return undergraduateSemesters(parsed), nil +} + +func undergraduateSemesters(parsed []ustc.UndergraduateSemester) []Semester { + if parsed == nil { + return nil + } + semesters := make([]Semester, len(parsed)) + for i, semester := range parsed { + semesters[i] = Semester(semester) + } + return semesters } func PickSemester(semesters []Semester, semesterID int) (Semester, error) { @@ -494,7 +510,7 @@ func fetchCurrentCourseTable(ctx context.Context, client *http.Client, semesterI func fetchCatalogLessons(ctx context.Context, client *http.Client, semesterID int) ([]catalogLesson, error) { var lessons []catalogLesson for page := 1; ; page++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(catalogLessonURL, semesterID, page, 500), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.CatalogLessonsURL(semesterID, page, 500), nil) if err != nil { return nil, err } @@ -514,16 +530,21 @@ func fetchCatalogLessons(ctx context.Context, client *http.Client, semesterID in return nil, fmt.Errorf("read catalog lessons: %w", err) } - var direct []catalogLesson - if err := json.Unmarshal(body, &direct); err == nil { - return direct, nil - } - - var payload pageResponse[catalogLesson] - if err := json.Unmarshal(body, &payload); err != nil { + payload, err := ustc.ParseCatalogLessonPage(body) + if err != nil { return nil, fmt.Errorf("decode catalog lessons: %w", err) } - lessons = append(lessons, payload.Rows...) + var rows []catalogLesson + if payload.Rows != nil { + rows = make([]catalogLesson, len(payload.Rows)) + for i, lesson := range payload.Rows { + rows[i] = catalogLesson(lesson) + } + } + if payload.Direct { + return rows, nil + } + lessons = append(lessons, rows...) if payload.TotalPage == 0 || page >= payload.TotalPage { break } @@ -580,18 +601,26 @@ func fetchJWScoreReport(ctx context.Context, client *http.Client) (ScoreReport, } func fetchJWScoreSemesters(ctx context.Context, client *http.Client) ([]Semester, error) { - var semesters []Semester - if err := fetchJSON(ctx, client, ustc.ScoreSemestersURL, nil, &semesters); err != nil { + var data json.RawMessage + if err := fetchJSON(ctx, client, ustc.ScoreSemestersURL, nil, &data); err != nil { return nil, fmt.Errorf("fetch score semesters: %w", err) } - return semesters, nil + parsed, err := ustc.ParseUndergraduateSemesters(data) + if err != nil { + return nil, fmt.Errorf("fetch score semesters: decode %s: %w", ustc.ScoreSemestersURL, err) + } + return undergraduateSemesters(parsed), nil } -func fetchJWScoreTypes(ctx context.Context, client *http.Client) ([]scoreSheetType, error) { - var types []scoreSheetType - if err := fetchJSON(ctx, client, ustc.ScoreTypesURL, nil, &types); err != nil { +func fetchJWScoreTypes(ctx context.Context, client *http.Client) ([]ustc.ScoreSheetType, error) { + var data json.RawMessage + if err := fetchJSON(ctx, client, ustc.ScoreTypesURL, nil, &data); err != nil { return nil, fmt.Errorf("fetch score transcript types: %w", err) } + types, err := ustc.ParseScoreSheetTypes(data) + if err != nil { + return nil, fmt.Errorf("fetch score transcript types: decode %s: %w", ustc.ScoreTypesURL, err) + } return types, nil } @@ -794,31 +823,6 @@ func normalizeSpace(value string) string { return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") } -func splitTeachers(value string) []string { - parts := regexp.MustCompile(`[、,,;/;]+`).Split(value, -1) - teachers := make([]string, 0, len(parts)) - for _, part := range parts { - name := compactWhitespace(part) - if name != "" { - teachers = append(teachers, name) - } - } - slices.Sort(teachers) - return slices.Compact(teachers) -} - -func parseLeadingInt(value string) int { - match := regexp.MustCompile(`^\d+`).FindString(strings.TrimSpace(value)) - if match == "" { - return 0 - } - parsed, err := strconv.Atoi(match) - if err != nil { - return 0 - } - return parsed -} - func attrOr(selection *goquery.Selection, name, fallback string) string { if value, ok := selection.Attr(name); ok { return value @@ -826,11 +830,6 @@ func attrOr(selection *goquery.Selection, name, fallback string) string { return fallback } -type pageResponse[T any] struct { - Rows []T `json:"rows"` - TotalPage int `json:"totalPage"` -} - type catalogLesson ustc.Lesson func (l catalogLesson) toCurriculumItem(semesterID int) CurriculumItem { @@ -853,10 +852,6 @@ func (l catalogLesson) toCurriculumItem(semesterID int) CurriculumItem { } } -type scoreSheetType struct { - ID int `json:"id"` -} - 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))) diff --git a/internal/school/graduate.go b/internal/school/graduate.go index 2172489..ac20de0 100644 --- a/internal/school/graduate.go +++ b/internal/school/graduate.go @@ -7,135 +7,22 @@ import ( "io" "net/http" "net/url" - "regexp" - "strconv" "strings" "time" -) - -const ( - graduateYJS1ScheduleAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskbcx" - graduateYJS1ScheduleTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xnxqxxcx.do" - graduateYJS1ScheduleRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xskbxxcx.do" - - graduateYJS1ScoreAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdcj" - graduateYJS1ScoreInfoURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/queryInfo.do" - graduateYJS1ScoreProfileURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/hqxh.do" - graduateYJS1ScoreRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/xscjcx.do" - graduateYJS1ExamAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskssbcx" - graduateYJS1ExamTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/hqxnxq.do" - graduateYJS1ExamRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/xscxkssbxx.do" - - graduateYJS1HomeworkAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdzy" - graduateYJS1HomeworkTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/hqxnxq.do" - graduateYJS1HomeworkRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/wdzycx.do" + "github.com/Life-USTC/client-core/ustc" ) -var graduateYJS1UserIDPattern = regexp.MustCompile(`"USERID":"([^"]+)"`) - -type graduateYJS1RowsResponse[T any] struct { - Code string `json:"code"` - Msg string `json:"msg"` - Datas map[string]graduateYJS1RowsResponseSet[T] `json:"datas"` -} - -type graduateYJS1RowsResponseSet[T any] struct { - Rows []T `json:"rows"` -} - -type graduateYJS1Term struct { - DM string `json:"DM"` - MC string `json:"MC"` - SFDQXQ string `json:"SFDQXQ"` - QSSJ string `json:"QSSJ"` - JZSJ string `json:"JZSJ"` -} - -type graduateYJS1ScheduleRow struct { - WID string `json:"WID"` - BJMC string `json:"BJMC"` - KCDM string `json:"KCDM"` - KCMC string `json:"KCMC"` - PKSJDD string `json:"PKSJDD"` - RKJS string `json:"RKJS"` - ZCMC string `json:"ZCMC"` -} - -type graduateYJS1ScoreInfoRow struct { - XH string `json:"XH"` - PYCCDMDisplay string `json:"PYCCDM_DISPLAY"` - ZYDMDisplay string `json:"ZYDM_DISPLAY"` - YXDMDisplay string `json:"YXDM_DISPLAY"` - DSXM string `json:"DSXM"` - NJDMDisplay string `json:"NJDM_DISPLAY"` - ZCZTDisplay string `json:"ZCZT_DISPLAY"` - YJBYSJ string `json:"YJBYSJ"` -} - -type graduateYJS1ScoreProfile struct { - Code string `json:"code"` - Msg string `json:"msg"` - XH string `json:"XH"` - XSCJXX graduateYJS1ScoreProfileRow `json:"xscjxx"` -} - -type graduateYJS1ScoreProfileRow struct { - PYCCDMDisplay string `json:"PYCCDM_DISPLAY"` - ZYDMDisplay string `json:"ZYDM_DISPLAY"` - YXDMDisplay string `json:"YXDM_DISPLAY"` - DSXM string `json:"DSXM"` - NJDMDisplay string `json:"NJDM_DISPLAY"` - ZCZTDisplay string `json:"ZCZT_DISPLAY"` - YJBYSJ string `json:"YJBYSJ"` - PJJDZ string `json:"PJJDZ"` -} - -type graduateYJS1ScoreRow struct { - XNXQDM string `json:"XNXQDM"` - XNXQDMDisplay string `json:"XNXQDM_DISPLAY"` - BJMC string `json:"BJMC"` - KCDM string `json:"KCDM"` - KCMC string `json:"KCMC"` - CJFZDMDisplay string `json:"CJFZDM_DISPLAY"` - CJJLDisplay string `json:"CJJL_DISPLAY"` - XF any `json:"XF"` - CJ any `json:"CJ"` - JDZ any `json:"JDZ"` -} - -type graduateYJS1ExamRow struct { - BJMC string `json:"BJMC"` - KCDM string `json:"KCDM"` - KCMC string `json:"KCMC"` - KSDD string `json:"KSDD"` - KSKSSJ string `json:"KSKSSJ"` - KSJSSJ string `json:"KSJSSJ"` - KSXS string `json:"KSXS"` - KSXSDisplay string `json:"KSXS_DISPLAY"` - SBZT string `json:"SBZT"` - SBZTDisplay string `json:"SBZT_DISPLAY"` - ZWH string `json:"ZWH"` -} - -type graduateYJS1HomeworkRow struct { - WID string `json:"WID"` - XSZYWID string `json:"XSZYWID"` - KCMC string `json:"KCMC"` - ZYMC string `json:"ZYMC"` - ZYKSSJ string `json:"ZYKSSJ"` - ZYJZSJ string `json:"ZYJZSJ"` - ZYSCSJ string `json:"ZYSCSJ"` - CJ any `json:"CJ"` -} - func (c *Client) fetchGraduateSemesters(ctx context.Context) ([]Semester, error) { _, terms, err := c.graduateScheduleSession(ctx) if err != nil { return nil, err } - - return graduateYJS1SemestersFromTerms(terms), nil + semesters := make([]Semester, 0, len(terms)) + for _, term := range terms { + semesters = append(semesters, Semester(term)) + } + return semesters, nil } func (c *Client) fetchGraduateCurriculum(ctx context.Context, semesterID int) (Semester, []CurriculumItem, error) { @@ -143,45 +30,35 @@ func (c *Client) fetchGraduateCurriculum(ctx context.Context, semesterID int) (S if err != nil { return Semester{}, nil, err } - selected, err := pickGraduateYJS1Term(terms, semesterID) if err != nil { return Semester{}, nil, err } - - querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ - {Name: "XNXQDM", Builder: "equal", LinkOpt: "AND", BuilderList: "cbl_String", Value: selected.Code}, - }) + data, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1ScheduleAppURL, ustc.YJS1ScheduleRowsURL, ustc.YJS1ScheduleRowsForm(selected.Code)) if err != nil { return Semester{}, nil, err } - - rows, err := fetchGraduateYJS1Rows[graduateYJS1ScheduleRow](ctx, client, graduateYJS1ScheduleAppURL, graduateYJS1ScheduleRowsURL, url.Values{ - "querySetting": {querySetting}, - "pageSize": {"999"}, - "pageNumber": {"1"}, - }) + rows, err := ustc.ParseYJS1Curriculum(data, selected.ID) if err != nil { return Semester{}, nil, err } - - return selected, graduateYJS1CurriculumItemsFromRows(selected, rows), nil + items := make([]CurriculumItem, 0, len(rows)) + for _, row := range rows { + items = append(items, CurriculumItem(row)) + } + return selected, items, nil } -func (c *Client) graduateScheduleSession(ctx context.Context) (*http.Client, []graduateYJS1Term, error) { +func (c *Client) graduateScheduleSession(ctx context.Context) (*http.Client, []ustc.YJS1Semester, error) { if c.graduateScheduleClient == nil { - client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ScheduleAppURL) + client, err := newGraduateYJS1Client(ctx, c.creds, ustc.YJS1ScheduleAppURL) if err != nil { return nil, nil, err } c.graduateScheduleClient = client } if c.graduateScheduleTerms == nil { - terms, err := fetchGraduateYJS1Terms(ctx, c.graduateScheduleClient, graduateYJS1ScheduleAppURL, graduateYJS1ScheduleTermURL, url.Values{ - "SFSY": {"1"}, - "pageSize": {"100"}, - "pageNumber": {"1"}, - }) + terms, err := fetchGraduateYJS1Terms(ctx, c.graduateScheduleClient, ustc.YJS1ScheduleAppURL, ustc.YJS1ScheduleTermURL, ustc.YJS1ScheduleTermsForm()) if err != nil { return nil, nil, err } @@ -191,12 +68,15 @@ func (c *Client) graduateScheduleSession(ctx context.Context) (*http.Client, []g } func (c *Client) fetchGraduateExams(ctx context.Context, semesterID int) ([]ExamItem, error) { - client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ExamAppURL) + client, err := newGraduateYJS1Client(ctx, c.creds, ustc.YJS1ExamAppURL) if err != nil { return nil, err } + return fetchGraduateYJS1Exams(ctx, client, semesterID) +} - terms, err := fetchGraduateYJS1Terms(ctx, client, graduateYJS1ExamAppURL, graduateYJS1ExamTermURL, nil) +func fetchGraduateYJS1Exams(ctx context.Context, client *http.Client, semesterID int) ([]ExamItem, error) { + terms, err := fetchGraduateYJS1Terms(ctx, client, ustc.YJS1ExamAppURL, ustc.YJS1ExamTermURL, nil) if err != nil { return nil, err } @@ -204,71 +84,71 @@ func (c *Client) fetchGraduateExams(ctx context.Context, semesterID int) ([]Exam if err != nil { return nil, err } - - querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ - {Name: "XNXQDM", Caption: "学年学期", Builder: "m_value_equal", LinkOpt: "AND", Value: selected.Code}, - {Name: "SBZT", Caption: "申报状态", Builder: "equal", LinkOpt: "AND", Value: "1"}, - }) + data, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1ExamAppURL, ustc.YJS1ExamRowsURL, ustc.YJS1ExamRowsForm(selected.Code)) if err != nil { return nil, err } - - rows, err := fetchGraduateYJS1Rows[graduateYJS1ExamRow](ctx, client, graduateYJS1ExamAppURL, graduateYJS1ExamRowsURL, url.Values{ - "querySetting": {querySetting}, - "pageSize": {"999"}, - "pageNumber": {"1"}, - }) + rows, err := ustc.ParseYJS1Exams(data) if err != nil { return nil, err } - - return graduateYJS1ExamItemsFromRows(rows), nil + items := make([]ExamItem, 0, len(rows)) + for _, row := range rows { + items = append(items, ExamItem(row)) + } + return items, nil } func (c *Client) fetchGraduateScores(ctx context.Context) (ScoreReport, error) { - client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ScoreAppURL) + client, err := newGraduateYJS1Client(ctx, c.creds, ustc.YJS1ScoreAppURL) if err != nil { return ScoreReport{}, err } + return fetchGraduateYJS1ScoreReport(ctx, client) +} - infoRows, err := fetchGraduateYJS1Rows[graduateYJS1ScoreInfoRow](ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreInfoURL, nil) +func fetchGraduateYJS1ScoreReport(ctx context.Context, client *http.Client) (ScoreReport, error) { + infoData, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1ScoreAppURL, ustc.YJS1ScoreInfoURL, nil) if err != nil { return ScoreReport{}, err } - - var profile graduateYJS1ScoreProfile - if err := postGraduateYJS1FormJSON(ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreProfileURL, nil, &profile); err != nil { + info, err := ustc.ParseYJS1ScoreInfo(infoData) + if err != nil { return ScoreReport{}, err } - if profile.Code != "" && profile.Code != "0" { - return ScoreReport{}, fmt.Errorf("graduate score profile request failed: %s", profile.Msg) + profileData, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1ScoreAppURL, ustc.YJS1ScoreProfileURL, nil) + if err != nil { + return ScoreReport{}, err } - - rows, err := fetchGraduateYJS1Rows[graduateYJS1ScoreRow](ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreRowsURL, url.Values{ - "pageSize": {"999"}, - "pageNumber": {"1"}, - }) + profile, err := ustc.ParseYJS1ScoreProfile(profileData) if err != nil { return ScoreReport{}, err } - - report := ScoreReport{ - Items: graduateYJS1ScoreItemsFromRows(rows), - Summary: buildGraduateYJS1ScoreSummary(firstGraduateYJS1ScoreInfoRow(infoRows), profile), + data, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1ScoreAppURL, ustc.YJS1ScoreRowsURL, ustc.YJS1ScoreRowsForm()) + if err != nil { + return ScoreReport{}, err + } + parsed, err := ustc.ParseYJS1Scores(data, info, profile) + if err != nil { + return ScoreReport{}, err + } + report := ScoreReport{Summary: parsed.Summary, Items: make([]ScoreItem, 0, len(parsed.Items))} + for _, item := range parsed.Items { + report.Items = append(report.Items, ScoreItem(item)) } - SortScores(report.Items) return report, nil } func (c *Client) fetchGraduateHomework(ctx context.Context) ([]HomeworkItem, error) { - client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1HomeworkAppURL) + client, err := newGraduateYJS1Client(ctx, c.creds, ustc.YJS1HomeworkAppURL) if err != nil { return nil, err } + return fetchGraduateYJS1Homework(ctx, client) +} - terms, err := fetchGraduateYJS1Terms(ctx, client, graduateYJS1HomeworkAppURL, graduateYJS1HomeworkTermURL, url.Values{ - "*order": {"-XNDM,-XQDM"}, - }) +func fetchGraduateYJS1Homework(ctx context.Context, client *http.Client) ([]HomeworkItem, error) { + terms, err := fetchGraduateYJS1Terms(ctx, client, ustc.YJS1HomeworkAppURL, ustc.YJS1HomeworkTermURL, ustc.YJS1HomeworkTermsForm()) if err != nil { return nil, err } @@ -276,65 +156,42 @@ func (c *Client) fetchGraduateHomework(ctx context.Context) ([]HomeworkItem, err if err != nil { return nil, err } - - userID, err := fetchGraduateYJS1UserID(ctx, client, graduateYJS1HomeworkAppURL) + userID, err := fetchGraduateYJS1UserID(ctx, client, ustc.YJS1HomeworkAppURL) if err != nil { return nil, err } - - querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ - {Name: "XNXQDM", Builder: "m_value_equal", Value: selected.Code}, - }) + data, err := fetchGraduateYJS1JSON(ctx, client, ustc.YJS1HomeworkAppURL, ustc.YJS1HomeworkRowsURL, ustc.YJS1HomeworkRowsForm(selected.Code, userID)) if err != nil { return nil, err } - - rows, err := fetchGraduateYJS1Rows[graduateYJS1HomeworkRow](ctx, client, graduateYJS1HomeworkAppURL, graduateYJS1HomeworkRowsURL, url.Values{ - "XH": {userID}, - "querySetting": {querySetting}, - "pageSize": {"999"}, - "pageNumber": {"1"}, - }) + rows, err := ustc.ParseYJS1Homework(data, time.Now(), time.Local) if err != nil { return nil, err } - - return graduateYJS1HomeworkItemsFromRows(rows, time.Now()), nil -} - -type graduateYJS1Query struct { - Name string `json:"name"` - Caption string `json:"caption,omitempty"` - Builder string `json:"builder,omitempty"` - LinkOpt string `json:"linkOpt,omitempty"` - BuilderList string `json:"builderList,omitempty"` - Value string `json:"value,omitempty"` + items := make([]HomeworkItem, 0, len(rows)) + for _, row := range rows { + items = append(items, HomeworkItem{ + ID: row.ID, Title: row.Title, CourseName: row.CourseName, + StartAt: row.StartAt, EndAt: row.EndAt, Status: row.Status, + }) + } + return items, nil } -func graduateYJS1QuerySetting(filters []graduateYJS1Query) (string, error) { - data, err := json.Marshal(filters) +func fetchGraduateYJS1Terms(ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) ([]ustc.YJS1Semester, error) { + data, err := fetchGraduateYJS1JSON(ctx, client, appPageURL, rawURL, form) if err != nil { - return "", err + return nil, err } - return string(data), nil -} - -func fetchGraduateYJS1Terms(ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) ([]graduateYJS1Term, error) { - return fetchGraduateYJS1Rows[graduateYJS1Term](ctx, client, appPageURL, rawURL, form) + return ustc.ParseYJS1Terms(data) } -func fetchGraduateYJS1Rows[T any](ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) ([]T, error) { - var response graduateYJS1RowsResponse[T] - if err := postGraduateYJS1FormJSON(ctx, client, appPageURL, rawURL, form, &response); err != nil { +func fetchGraduateYJS1JSON(ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) (json.RawMessage, error) { + var data json.RawMessage + if err := postGraduateYJS1FormJSON(ctx, client, appPageURL, rawURL, form, &data); err != nil { return nil, err } - if response.Code != "" && response.Code != "0" { - return nil, fmt.Errorf("graduate yjs1 request failed: %s", response.Msg) - } - for _, data := range response.Datas { - return data.Rows, nil - } - return nil, nil + return data, nil } func newGraduateYJS1Client(ctx context.Context, creds Credentials, appPageURL string) (*http.Client, error) { @@ -401,309 +258,25 @@ func fetchGraduateYJS1UserID(ctx context.Context, client *http.Client, appPageUR return "", err } - match := graduateYJS1UserIDPattern.FindSubmatch(body) - if len(match) < 2 { - return "", fmt.Errorf("could not resolve graduate student ID from %s", appPageURL) - } - return string(match[1]), nil + return ustc.ParseYJS1UserID(body, appPageURL) } -func pickGraduateYJS1Term(terms []graduateYJS1Term, semesterID int) (Semester, error) { - semesters := graduateYJS1SemestersFromTerms(terms) - if len(semesters) == 0 { +func pickGraduateYJS1Term(terms []ustc.YJS1Semester, semesterID int) (Semester, error) { + if len(terms) == 0 { return Semester{}, fmt.Errorf("no graduate semesters available") } if semesterID == 0 { - for i, term := range terms { - if term.SFDQXQ == "1" { - return semesters[i], nil + for _, term := range terms { + if term.IsLast { + return Semester(term), nil } } - return semesters[0], nil + return Semester(terms[0]), nil } - for _, semester := range semesters { - if semester.ID == semesterID { - return semester, nil + for _, term := range terms { + if term.ID == semesterID { + return Semester(term), nil } } return Semester{}, fmt.Errorf("graduate semester %d was not found", semesterID) } - -func graduateYJS1SemestersFromTerms(rows []graduateYJS1Term) []Semester { - semesters := make([]Semester, 0, len(rows)) - for _, row := range rows { - id, _ := strconv.Atoi(row.DM) - name := firstNonEmpty(row.MC, graduateYJS1SemesterNameFromCode(row.DM)) - semesters = append(semesters, Semester{ - ID: id, - Code: row.DM, - SemesterCn: name, - SemesterEn: name, - StartDate: row.QSSJ, - EndDate: row.JZSJ, - IsLast: row.SFDQXQ == "1", - }) - } - return semesters -} - -func graduateYJS1SemesterNameFromCode(code string) string { - if len(code) < 5 { - return code - } - year, err := strconv.Atoi(code[:4]) - if err != nil { - return code - } - switch code[4:] { - case "1": - return fmt.Sprintf("%d年秋季学期", year) - case "2": - return fmt.Sprintf("%d年春季学期", year+1) - case "3": - return fmt.Sprintf("%d年夏季学期", year+1) - default: - return code - } -} - -func graduateYJS1CurriculumItemsFromRows(semester Semester, rows []graduateYJS1ScheduleRow) []CurriculumItem { - items := make([]CurriculumItem, 0, len(rows)) - for _, row := range rows { - items = append(items, CurriculumItem{ - LessonID: parseLeadingInt(row.WID), - SemesterID: semester.ID, - LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.WID)), - CourseCode: compactWhitespace(row.KCDM), - CourseName: compactWhitespace(row.KCMC), - Teachers: splitTeachers(row.RKJS), - Schedule: graduateYJS1ScheduleString(row.PKSJDD, row.ZCMC), - }) - } - SortCurriculum(items) - return items -} - -func graduateYJS1ScheduleString(lessonSchedule, weekRanges string) string { - lessonSchedule = compactWhitespace(lessonSchedule) - weekRanges = compactWhitespace(strings.ReplaceAll(weekRanges, "~", "-")) - if lessonSchedule == "" { - return weekRanges - } - if weekRanges == "" { - return lessonSchedule - } - - scheduleParts := splitSemicolonParts(lessonSchedule) - weekParts := splitSemicolonParts(weekRanges) - if len(scheduleParts) == len(weekParts) { - parts := make([]string, 0, len(scheduleParts)) - for i, schedule := range scheduleParts { - if week := weekParts[i]; week != "" { - parts = append(parts, schedule+" ["+week+"周]") - continue - } - parts = append(parts, schedule) - } - return strings.Join(parts, "; ") - } - - return lessonSchedule + " [" + weekRanges + "周]" -} - -func graduateYJS1ScoreItemsFromRows(rows []graduateYJS1ScoreRow) []ScoreItem { - items := make([]ScoreItem, 0, len(rows)) - for _, row := range rows { - semesterID, _ := strconv.Atoi(row.XNXQDM) - items = append(items, ScoreItem{ - SemesterID: semesterID, - SemesterName: compactWhitespace(firstNonEmpty(row.XNXQDMDisplay, graduateYJS1SemesterNameFromCode(row.XNXQDM))), - CourseName: compactWhitespace(row.KCMC), - LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.KCDM)), - CourseCode: compactWhitespace(row.KCDM), - Credits: graduateYJS1Float(row.XF), - GradePoint: graduateYJS1Float(row.JDZ), - Score: graduateYJS1String(row.CJ), - GradeText: graduateYJS1Join(" / ", row.CJFZDMDisplay, row.CJJLDisplay), - }) - } - return items -} - -func firstGraduateYJS1ScoreInfoRow(rows []graduateYJS1ScoreInfoRow) graduateYJS1ScoreInfoRow { - if len(rows) == 0 { - return graduateYJS1ScoreInfoRow{} - } - return rows[0] -} - -func buildGraduateYJS1ScoreSummary(info graduateYJS1ScoreInfoRow, profile graduateYJS1ScoreProfile) json.RawMessage { - summary := map[string]any{} - if studentID := compactWhitespace(firstNonEmpty(profile.XH, info.XH)); studentID != "" { - summary["studentId"] = studentID - } - if program := compactWhitespace(firstNonEmpty(profile.XSCJXX.PYCCDMDisplay, info.PYCCDMDisplay)); program != "" { - summary["program"] = program - } - if major := compactWhitespace(firstNonEmpty(profile.XSCJXX.ZYDMDisplay, info.ZYDMDisplay)); major != "" { - summary["major"] = major - } - if department := compactWhitespace(firstNonEmpty(profile.XSCJXX.YXDMDisplay, info.YXDMDisplay)); department != "" { - summary["department"] = department - } - if advisor := compactWhitespace(firstNonEmpty(profile.XSCJXX.DSXM, info.DSXM)); advisor != "" { - summary["advisor"] = advisor - } - if grade := compactWhitespace(firstNonEmpty(profile.XSCJXX.NJDMDisplay, info.NJDMDisplay)); grade != "" { - summary["grade"] = grade - } - if status := compactWhitespace(firstNonEmpty(profile.XSCJXX.ZCZTDisplay, info.ZCZTDisplay)); status != "" { - summary["status"] = status - } - if graduation := compactWhitespace(firstNonEmpty(profile.XSCJXX.YJBYSJ, info.YJBYSJ)); graduation != "" { - summary["expectedGraduation"] = graduation - } - if gpa := strings.TrimSpace(profile.XSCJXX.PJJDZ); gpa != "" { - if value, err := strconv.ParseFloat(gpa, 64); err == nil { - summary["averageGradePoint"] = value - } else { - summary["averageGradePoint"] = gpa - } - } - if len(summary) == 0 { - return nil - } - data, err := json.Marshal(summary) - if err != nil { - return nil - } - return data -} - -func graduateYJS1ExamItemsFromRows(rows []graduateYJS1ExamRow) []ExamItem { - items := make([]ExamItem, 0, len(rows)) - for _, row := range rows { - dateTime := compactWhitespace(strings.Trim(strings.Join([]string{ - strings.TrimSpace(row.KSKSSJ), - strings.TrimSpace(row.KSJSSJ), - }, " - "), " -")) - items = append(items, ExamItem{ - CourseName: compactWhitespace(row.KCMC), - LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.KCDM)), - DateTime: dateTime, - Location: compactWhitespace(row.KSDD), - ExamType: compactWhitespace(firstNonEmpty(row.KSXSDisplay, row.KSXS)), - Status: compactWhitespace(firstNonEmpty(row.SBZTDisplay, row.SBZT)), - Seat: compactWhitespace(row.ZWH), - }) - } - SortExams(items) - return items -} - -func graduateYJS1HomeworkItemsFromRows(rows []graduateYJS1HomeworkRow, now time.Time) []HomeworkItem { - items := make([]HomeworkItem, 0, len(rows)) - for _, row := range rows { - items = append(items, HomeworkItem{ - ID: firstNonEmpty(row.XSZYWID, row.WID), - Title: compactWhitespace(row.ZYMC), - CourseName: compactWhitespace(row.KCMC), - StartAt: compactWhitespace(row.ZYKSSJ), - EndAt: compactWhitespace(row.ZYJZSJ), - Status: graduateYJS1HomeworkStatus(row, now), - }) - } - SortHomework(items) - return items -} - -func graduateYJS1HomeworkStatus(row graduateYJS1HomeworkRow, now time.Time) string { - if graduateYJS1String(row.CJ) != "" { - return "graded" - } - if strings.TrimSpace(row.XSZYWID) != "" || strings.TrimSpace(row.ZYSCSJ) != "" { - return "submitted" - } - if deadline, ok := graduateYJS1ParseTime(row.ZYJZSJ); ok && now.After(deadline) { - return "overdue" - } - if start, ok := graduateYJS1ParseTime(row.ZYKSSJ); ok && now.Before(start) { - return "not started" - } - return "pending" -} - -func graduateYJS1ParseTime(value string) (time.Time, bool) { - value = strings.TrimSpace(value) - if value == "" { - return time.Time{}, false - } - for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02"} { - if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { - return parsed, true - } - } - return time.Time{}, false -} - -func graduateYJS1String(value any) string { - switch v := value.(type) { - case nil: - return "" - case string: - return compactWhitespace(v) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - case json.Number: - return v.String() - default: - return compactWhitespace(fmt.Sprint(v)) - } -} - -func graduateYJS1Float(value any) float64 { - switch v := value.(type) { - case nil: - return 0 - case float64: - return v - case json.Number: - f, _ := v.Float64() - return f - case string: - f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) - return f - default: - f, _ := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(v)), 64) - return f - } -} - -func graduateYJS1Join(sep string, values ...string) string { - parts := make([]string, 0, len(values)) - seen := map[string]struct{}{} - for _, value := range values { - value = compactWhitespace(value) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - parts = append(parts, value) - } - return strings.Join(parts, sep) -} - -func splitSemicolonParts(value string) []string { - rawParts := strings.Split(value, ";") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - part = compactWhitespace(part) - if part != "" { - parts = append(parts, part) - } - } - return parts -} diff --git a/internal/school/graduate_test.go b/internal/school/graduate_test.go index ac1db2a..f95e2f6 100644 --- a/internal/school/graduate_test.go +++ b/internal/school/graduate_test.go @@ -1,135 +1,427 @@ package school import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" "reflect" + "strings" "testing" "time" ) -func TestGraduateYJS1SemestersFromTermsMarksCurrent(t *testing.T) { - t.Parallel() +const ( + graduateTestScheduleApp = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskbcx" + graduateTestScheduleTerm = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xnxqxxcx.do" + graduateTestScheduleRows = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xskbxxcx.do" + graduateTestExamApp = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskssbcx" + graduateTestExamTerm = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/hqxnxq.do" + graduateTestExamRows = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/xscxkssbxx.do" + graduateTestScoreApp = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdcj" + graduateTestScoreInfo = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/queryInfo.do" + graduateTestScoreProfile = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/hqxh.do" + graduateTestScoreRows = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/xscjcx.do" + graduateTestHomeworkApp = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdzy" + graduateTestHomeworkTerm = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/hqxnxq.do" + graduateTestHomeworkRows = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/wdzycx.do" + graduateTestTerms = `{"code":"0","datas":{"terms":{"rows":[{"DM":"20251","MC":"2025年秋季学期","SFDQXQ":"0","QSSJ":"2025-09-01","JZSJ":"2026-01-20"},{"DM":"20252","SFDQXQ":"1","QSSJ":"2026-02-23","JZSJ":"2026-07-05"}]}}}` +) - items := graduateYJS1SemestersFromTerms([]graduateYJS1Term{ - {DM: "20251", MC: "2025年秋季学期", SFDQXQ: "0", QSSJ: "2025-09-01", JZSJ: "2026-01-20"}, - {DM: "20252", MC: "2026年春季学期", SFDQXQ: "1", QSSJ: "2026-02-23", JZSJ: "2026-07-05"}, +type graduateRequestFixture struct { + url, referer, body string + form url.Values + get bool + status int + networkError, readError error +} + +func graduateFixtureClient(t *testing.T, fixtures []graduateRequestFixture) *http.Client { + t.Helper() + calls := 0 + t.Cleanup(func() { + if calls != len(fixtures) { + t.Errorf("graduate requests = %d, want %d", calls, len(fixtures)) + } }) + return &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + if calls >= len(fixtures) { + t.Fatalf("unexpected graduate request: %s %s", r.Method, r.URL) + } + fixture := fixtures[calls] + calls++ + method := http.MethodPost + if fixture.get { + method = http.MethodGet + } + if r.Method != method || r.URL.String() != fixture.url || r.Header.Get("Referer") != fixture.referer || r.Header.Get("User-Agent") != schoolUserAgent { + t.Fatalf("graduate request %d = %s %s headers=%v, want %s %s referer=%s", calls, r.Method, r.URL, r.Header, method, fixture.url, fixture.referer) + } + if !fixture.get { + for name, want := range map[string]string{ + "Accept": "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Origin": "https://yjs1.ustc.edu.cn", + "X-Requested-With": "XMLHttpRequest", + } { + if r.Header.Get(name) != want { + t.Fatalf("graduate header %s = %q, want %q", name, r.Header.Get(name), want) + } + } + body, err := io.ReadAll(r.Body) + if err != nil || string(body) != fixture.form.Encode() { + t.Fatalf("graduate form = %q, %v; want %q", body, err, fixture.form.Encode()) + } + } + if fixture.networkError != nil { + return nil, fixture.networkError + } + status := fixture.status + if status == 0 { + status = http.StatusOK + } + response := importParserResponse(r, status, fixture.body) + if fixture.readError != nil { + response.Body = io.NopCloser(importParserReadError{err: fixture.readError}) + } + return response, nil + })} +} - if len(items) != 2 { - t.Fatalf("expected 2 semesters, got %d", len(items)) +func graduateScheduleFixtures(rows string) []graduateRequestFixture { + return []graduateRequestFixture{ + {url: graduateTestScheduleTerm, referer: graduateTestScheduleApp, form: url.Values{"SFSY": {"1"}, "pageSize": {"100"}, "pageNumber": {"1"}}, body: graduateTestTerms}, + {url: graduateTestScheduleRows, referer: graduateTestScheduleApp, form: url.Values{"querySetting": {`[{"name":"XNXQDM","builder":"equal","linkOpt":"AND","builderList":"cbl_String","value":"20252"}]`}, "pageSize": {"999"}, "pageNumber": {"1"}}, body: rows}, } - if items[0].IsLast { - t.Fatal("first semester should not be current") +} + +func graduateExamFixtures(rows string) []graduateRequestFixture { + return []graduateRequestFixture{ + {url: graduateTestExamTerm, referer: graduateTestExamApp, body: graduateTestTerms}, + {url: graduateTestExamRows, referer: graduateTestExamApp, form: url.Values{"querySetting": {`[{"name":"XNXQDM","caption":"学年学期","builder":"m_value_equal","linkOpt":"AND","value":"20252"},{"name":"SBZT","caption":"申报状态","builder":"equal","linkOpt":"AND","value":"1"}]`}, "pageSize": {"999"}, "pageNumber": {"1"}}, body: rows}, } - if !items[1].IsLast { - t.Fatal("second semester should be current") +} + +func graduateScoreFixtures(info, profile, rows string) []graduateRequestFixture { + return []graduateRequestFixture{ + {url: graduateTestScoreInfo, referer: graduateTestScoreApp, body: info}, + {url: graduateTestScoreProfile, referer: graduateTestScoreApp, body: profile}, + {url: graduateTestScoreRows, referer: graduateTestScoreApp, form: url.Values{"pageSize": {"999"}, "pageNumber": {"1"}}, body: rows}, } } -func TestGraduateYJS1CurriculumItemsFromRows(t *testing.T) { - t.Parallel() +func graduateHomeworkFixtures(rows string) []graduateRequestFixture { + return []graduateRequestFixture{ + {url: graduateTestHomeworkTerm, referer: graduateTestHomeworkApp, form: url.Values{"*order": {"-XNDM,-XQDM"}}, body: graduateTestTerms}, + {url: graduateTestHomeworkApp, referer: graduateTestHomeworkApp, get: true, body: ``}, + {url: graduateTestHomeworkRows, referer: graduateTestHomeworkApp, form: url.Values{"XH": {"PB +&/1"}, "querySetting": {`[{"name":"XNXQDM","builder":"m_value_equal","value":"20252"}]`}, "pageSize": {"999"}, "pageNumber": {"1"}}, body: rows}, + } +} - items := graduateYJS1CurriculumItemsFromRows(Semester{ID: 20252}, []graduateYJS1ScheduleRow{ - { - WID: "abc-row", - BJMC: "SA25113014.01", - KCDM: "Y0203220", - KCMC: "软件体系结构分析与设计", - PKSJDD: "周一 3-4节 软件楼101; 周三 7-8节 软件楼102", - RKJS: "张三、李四", - ZCMC: "1~8; 9~16", - }, - }) +func graduateTestClient(client *http.Client) *Client { + c := NewClient(Credentials{}, ProgramGraduate) + c.graduateScheduleClient = client + return c +} - want := []CurriculumItem{ - { - SemesterID: 20252, - LessonCode: "SA25113014.01", - CourseCode: "Y0203220", - CourseName: "软件体系结构分析与设计", - Teachers: []string{"张三", "李四"}, - Schedule: "周一 3-4节 软件楼101 [1-8周]; 周三 7-8节 软件楼102 [9-16周]", - }, +func TestGraduateYJS1ScheduleRequestsCacheAndOutput(t *testing.T) { + t.Parallel() + fixtures := graduateScheduleFixtures(`{"datas":{"schedule":{"rows":[ + {"WID":"abc-row","BJMC":" SA25113014.01 ","KCDM":" Y0203220 ","KCMC":" 软件体系结构分析与设计 ","PKSJDD":"周一 3-4节 软件楼101; 周三 7-8节 软件楼102","RKJS":"张三、李四、张三","ZCMC":"1~8; 9~16"}, + {"WID":"42-extra","BJMC":" A.01 ","KCDM":" MATH ","KCMC":" Math Course ","PKSJDD":" 周四 1-2节 ","RKJS":" B / A ","ZCMC":"1~16"} + ]}}} {"ignored":true}`) + fixtures[0].body += ` {"ignored":true}` + c := graduateTestClient(graduateFixtureClient(t, fixtures)) + semesters, err := c.FetchSemesters(context.Background()) + wantSemesters := []Semester{ + {ID: 20251, Code: "20251", SemesterCn: "2025年秋季学期", SemesterEn: "2025年秋季学期", StartDate: "2025-09-01", EndDate: "2026-01-20"}, + {ID: 20252, Code: "20252", SemesterCn: "2026年春季学期", SemesterEn: "2026年春季学期", StartDate: "2026-02-23", EndDate: "2026-07-05", IsLast: true}, } - if !reflect.DeepEqual(items, want) { - t.Fatalf("graduateYJS1CurriculumItemsFromRows() = %#v, want %#v", items, want) + if err != nil || !reflect.DeepEqual(semesters, wantSemesters) { + t.Fatalf("graduate semesters = %#v, %v; want %#v", semesters, err, wantSemesters) + } + selected, items, err := c.FetchCurriculum(context.Background(), 0) + wantItems := []CurriculumItem{ + {SemesterID: 20252, LessonID: 42, LessonCode: "A.01", CourseCode: "MATH", CourseName: "Math Course", Teachers: []string{"A", "B"}, Schedule: "周四 1-2节 [1-16周]"}, + {SemesterID: 20252, LessonCode: "SA25113014.01", CourseCode: "Y0203220", CourseName: "软件体系结构分析与设计", Teachers: []string{"张三", "李四"}, Schedule: "周一 3-4节 软件楼101 [1-8周]; 周三 7-8节 软件楼102 [9-16周]"}, + } + if err != nil || selected != wantSemesters[1] || !reflect.DeepEqual(items, wantItems) { + t.Fatalf("graduate curriculum = %#v, %#v, %v; want %#v", selected, items, err, wantItems) + } + again, err := c.FetchSemesters(context.Background()) + if err != nil || !reflect.DeepEqual(again, wantSemesters) { + t.Fatalf("cached graduate semesters = %#v, %v", again, err) } } -func TestGraduateYJS1ScoreItemsFromRows(t *testing.T) { +func TestGraduateYJS1SemesterSelectionAndEmptyCache(t *testing.T) { t.Parallel() + for _, tc := range []struct { + name string + requested int + current bool + wantCode, wantError string + }{ + {name: "current", current: true, wantCode: "20252"}, + {name: "first source term", wantCode: "20251"}, + {name: "explicit older term", requested: 20251, current: true, wantCode: "20251"}, + {name: "missing term", requested: 17, current: true, wantError: "graduate semester 17 was not found"}, + {name: "negative is explicit", requested: -1, current: true, wantError: "graduate semester -1 was not found"}, + } { + t.Run(tc.name, func(t *testing.T) { + fixtures := graduateScheduleFixtures(`{}`) + if !tc.current { + fixtures[0].body = strings.ReplaceAll(fixtures[0].body, `"SFDQXQ":"1"`, `"SFDQXQ":"0"`) + } + if tc.wantError != "" { + fixtures = fixtures[:1] + } else { + fixtures[1].form.Set("querySetting", strings.ReplaceAll(fixtures[1].form.Get("querySetting"), "20252", tc.wantCode)) + } + selected, items, err := graduateTestClient(graduateFixtureClient(t, fixtures)).FetchCurriculum(context.Background(), tc.requested) + if tc.wantError != "" { + if err == nil || err.Error() != tc.wantError || selected != (Semester{}) || items != nil { + t.Fatalf("selection = %#v, %#v, %v", selected, items, err) + } + } else if err != nil || selected.Code != tc.wantCode || items == nil || len(items) != 0 { + t.Fatalf("selection = %#v, %#v, %v", selected, items, err) + } + }) + } + for _, tc := range []struct { + name, body string + cached bool + }{ + {"missing datas", `{}`, false}, {"null response", `null`, false}, {"null rows", `{"datas":{"x":{"rows":null}}}`, false}, {"empty rows", `{"datas":{"x":{"rows":[]}}}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := graduateScheduleFixtures("")[0] + fixture.body = tc.body + fixtures := []graduateRequestFixture{fixture} + if !tc.cached { + fixtures = append(fixtures, fixture, fixture) + } + c := graduateTestClient(graduateFixtureClient(t, fixtures)) + for range 2 { + items, err := c.FetchSemesters(context.Background()) + encoded, _ := json.Marshal(items) + if err != nil || string(encoded) != "[]" { + t.Fatalf("empty semesters = %s, %v", encoded, err) + } + } + _, items, err := c.FetchCurriculum(context.Background(), 0) + if err == nil || err.Error() != "no graduate semesters available" || items != nil { + t.Fatalf("empty curriculum = %#v, %v", items, err) + } + }) + } +} - items := graduateYJS1ScoreItemsFromRows([]graduateYJS1ScoreRow{ - { - XNXQDM: "20252", - XNXQDMDisplay: "2026年春季学期", - BJMC: "SA25113014.01", - KCDM: "Y0203220", - KCMC: "软件体系结构分析与设计", - CJFZDMDisplay: "百分制", - CJJLDisplay: "正常考试", - XF: 2.0, - CJ: 87.0, - JDZ: 3.7, - }, - }) +func TestGraduateYJS1ScoreRequestsAndOutput(t *testing.T) { + t.Parallel() + fixtures := graduateScoreFixtures( + `{"datas":{"info":{"rows":[{"XH":" info-id ","PYCCDM_DISPLAY":"硕士","ZYDM_DISPLAY":" 信息专业 ","YXDM_DISPLAY":"信息学院","DSXM":" 信息导师 ","NJDM_DISPLAY":"2025","ZCZT_DISPLAY":"在籍","YJBYSJ":"2028-06"},{"XH":"ignored"}]}}} {"ignored":true}`, + `{"code":"0","XH":" profile-id ","xscjxx":{"PYCCDM_DISPLAY":" 博士 ","ZYDM_DISPLAY":" 优先专业 ","PJJDZ":" 3.7 "}} {"ignored":true}`, + `{"datas":{"scores":{"rows":[{"XNXQDM":"20251","KCDM":" OLD ","KCMC":"Old","CJ":"通过","JDZ":null},{"XNXQDM":"20252","XNXQDM_DISPLAY":"2026年春季学期","BJMC":"SA25113014.01","KCDM":"Y0203220","KCMC":" 软件体系结构分析与设计 ","CJFZDM_DISPLAY":"百分制","CJJL_DISPLAY":"正常考试","XF":2,"CJ":87,"JDZ":3.7},{"XNXQDM":"20252","KCDM":"ZERO","KCMC":"A","CJFZDM_DISPLAY":"通过","CJJL_DISPLAY":"通过","XF":"0","CJ":0,"JDZ":"bad"}]}}} {"ignored":true}`) + report, err := fetchGraduateYJS1ScoreReport(context.Background(), graduateFixtureClient(t, fixtures)) + want := []ScoreItem{ + {SemesterID: 20252, SemesterName: "2026年春季学期", LessonCode: "ZERO", CourseCode: "ZERO", CourseName: "A", Score: "0", GradeText: "通过"}, + {SemesterID: 20252, SemesterName: "2026年春季学期", LessonCode: "SA25113014.01", CourseCode: "Y0203220", CourseName: "软件体系结构分析与设计", Credits: 2, GradePoint: 3.7, Score: "87", GradeText: "百分制 / 正常考试"}, + {SemesterID: 20251, SemesterName: "2025年秋季学期", LessonCode: "OLD", CourseCode: "OLD", CourseName: "Old", Score: "通过"}, + } + if err != nil || !reflect.DeepEqual(report.Items, want) { + t.Fatalf("graduate scores = %#v, %v; want %#v", report, err, want) + } + var summary map[string]any + if err := json.Unmarshal(report.Summary, &summary); err != nil { + t.Fatal(err) + } + wantSummary := map[string]any{"studentId": "profile-id", "program": "博士", "major": "优先专业", "department": "信息学院", "advisor": "信息导师", "grade": "2025", "status": "在籍", "expectedGraduation": "2028-06", "averageGradePoint": 3.7} + if !reflect.DeepEqual(summary, wantSummary) { + t.Fatalf("graduate summary = %#v, want %#v", summary, wantSummary) + } + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var output struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(encoded, &output); err != nil { + t.Fatal(err) + } + if _, ok := output.Items[0]["gradePoint"]; ok || output.Items[0]["score"] != "0" || output.Items[1]["gradePoint"] != 3.7 { + t.Fatalf("numeric CLI output changed: %s", encoded) + } +} - if len(items) != 1 { - t.Fatalf("expected 1 score item, got %d", len(items)) +func TestGraduateYJS1ExamRequestsAndOutput(t *testing.T) { + t.Parallel() + fixtures := graduateExamFixtures(`{"datas":{"exams":{"rows":[{"BJMC":" B.01 ","KCDM":"ignored","KCMC":" Course B ","KSDD":" Room 1 ","KSKSSJ":" 2026-06-01 09:00 ","KSJSSJ":" 2026-06-01 11:00 ","KSXS":"raw","KSXS_DISPLAY":" 闭卷 ","SBZT":"1","SBZT_DISPLAY":" 已申报 ","ZWH":" 42 "},{"KCDM":" A.01 ","KCMC":"Course A","KSKSSJ":"2026-05-01","KSXS":"raw type","SBZT":"raw status"}]}}} {"ignored":true}`) + items, err := fetchGraduateYJS1Exams(context.Background(), graduateFixtureClient(t, fixtures), 0) + want := []ExamItem{ + {CourseName: "Course A", LessonCode: "A.01", DateTime: "2026-05-01", ExamType: "raw type", Status: "raw status"}, + {CourseName: "Course B", LessonCode: "B.01", DateTime: "2026-06-01 09:00 - 2026-06-01 11:00", Location: "Room 1", ExamType: "闭卷", Status: "已申报", Seat: "42"}, } - if items[0].SemesterID != 20252 { - t.Fatalf("unexpected semester ID: %d", items[0].SemesterID) + if err != nil || !reflect.DeepEqual(items, want) { + t.Fatalf("graduate exams = %#v, %v; want %#v", items, err, want) + } +} + +func TestGraduateYJS1HomeworkRequestsAndLocalTimezone(t *testing.T) { + previous := time.Local + time.Local = time.FixedZone("graduate-fixture", 9*60*60) + t.Cleanup(func() { time.Local = previous }) + now := time.Now() + past := now.Add(-time.Hour).Format("2006-01-02 15:04:05") + future := now.Add(time.Hour).Format("2006-01-02 15:04") + rows := []map[string]any{ + {"WID": "1", "KCMC": " Course A ", "ZYMC": " Overdue ", "ZYJZSJ": past}, + {"WID": "2", "XSZYWID": " sub-2 ", "KCMC": "Course B", "ZYMC": "Submitted", "ZYJZSJ": future}, + {"WID": "3", "KCMC": "Course C", "ZYMC": "Graded", "ZYJZSJ": future, "CJ": 0}, + {"WID": "4", "KCMC": "Course D", "ZYMC": "Later", "ZYKSSJ": future, "ZYJZSJ": future}, + {"WID": "5", "KCMC": "Course E", "ZYMC": "Pending", "ZYJZSJ": future}, } - if items[0].Score != "87" { - t.Fatalf("unexpected score string: %q", items[0].Score) + encoded, err := json.Marshal(map[string]any{"datas": map[string]any{"homework": map[string]any{"rows": rows}}}) + if err != nil { + t.Fatal(err) } - if items[0].GradeText != "百分制 / 正常考试" { - t.Fatalf("unexpected grade text: %q", items[0].GradeText) + fixtures := graduateHomeworkFixtures(string(encoded) + ` {"ignored":true}`) + items, err := fetchGraduateYJS1Homework(context.Background(), graduateFixtureClient(t, fixtures)) + want := []HomeworkItem{ + {ID: "1", CourseName: "Course A", Title: "Overdue", EndAt: past, Status: "overdue"}, + {ID: "sub-2", CourseName: "Course B", Title: "Submitted", EndAt: future, Status: "submitted"}, + {ID: "3", CourseName: "Course C", Title: "Graded", EndAt: future, Status: "graded"}, + {ID: "4", CourseName: "Course D", Title: "Later", StartAt: future, EndAt: future, Status: "not started"}, + {ID: "5", CourseName: "Course E", Title: "Pending", EndAt: future, Status: "pending"}, } - if items[0].Credits != 2 || items[0].GradePoint != 3.7 { - t.Fatalf("unexpected numeric mapping: credits=%v gradePoint=%v", items[0].Credits, items[0].GradePoint) + if err != nil || !reflect.DeepEqual(items, want) { + t.Fatalf("graduate homework = %#v, %v; want %#v", items, err, want) } } -func TestGraduateYJS1HomeworkItemsFromRows(t *testing.T) { +func TestGraduateYJS1EmptyOutputs(t *testing.T) { t.Parallel() + for _, body := range []string{`{}`, `null`, `{"datas":{"x":{"rows":null}}}`, `{"datas":{"x":{"rows":[]}}}`} { + t.Run(body, func(t *testing.T) { + _, curriculum, err := graduateTestClient(graduateFixtureClient(t, graduateScheduleFixtures(body))).FetchCurriculum(context.Background(), 0) + if err != nil || curriculum == nil || len(curriculum) != 0 { + t.Fatalf("empty curriculum = %#v, %v", curriculum, err) + } + exams, err := fetchGraduateYJS1Exams(context.Background(), graduateFixtureClient(t, graduateExamFixtures(body)), 0) + if err != nil || exams == nil || len(exams) != 0 { + t.Fatalf("empty exams = %#v, %v", exams, err) + } + homework, err := fetchGraduateYJS1Homework(context.Background(), graduateFixtureClient(t, graduateHomeworkFixtures(body))) + if err != nil || homework == nil || len(homework) != 0 { + t.Fatalf("empty homework = %#v, %v", homework, err) + } + scores, err := fetchGraduateYJS1ScoreReport(context.Background(), graduateFixtureClient(t, graduateScoreFixtures(body, `null`, body))) + encoded, _ := json.Marshal(scores) + if err != nil || string(encoded) != `{"items":[]}` { + t.Fatalf("empty scores = %s, %v", encoded, err) + } + }) + } +} - now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.Local) - items := graduateYJS1HomeworkItemsFromRows([]graduateYJS1HomeworkRow{ - { - WID: "1", - KCMC: "课程A", - ZYMC: "作业A", - ZYKSSJ: "2026-03-01 10:00:00", - ZYJZSJ: "2026-03-09 23:59:00", - }, - { - WID: "2", - XSZYWID: "sub-2", - KCMC: "课程B", - ZYMC: "作业B", - ZYKSSJ: "2026-03-01 10:00:00", - ZYJZSJ: "2026-03-20 23:59:00", - }, - { - WID: "3", - KCMC: "课程C", - ZYMC: "作业C", - ZYKSSJ: "2026-03-01 10:00:00", - ZYJZSJ: "2026-03-20 23:59:00", - CJ: 95.0, - }, - }, now) - - if len(items) != 3 { - t.Fatalf("expected 3 homework items, got %d", len(items)) - } - if items[0].Status != "overdue" { - t.Fatalf("expected overdue homework first, got %q", items[0].Status) - } - if items[1].Status != "submitted" { - t.Fatalf("expected submitted homework second after sorting by deadline, got %q", items[1].Status) - } - if items[2].Status != "graded" { - t.Fatalf("expected graded homework last, got %q", items[2].Status) +func TestGraduateYJS1RequestFailuresStopTheFlow(t *testing.T) { + t.Parallel() + flows := []struct { + name string + fixtures []graduateRequestFixture + fetch func(*http.Client) error + }{ + {"schedule", graduateScheduleFixtures(`{}`), func(client *http.Client) error { + _, _, err := graduateTestClient(client).FetchCurriculum(context.Background(), 0) + return err + }}, + {"exams", graduateExamFixtures(`{}`), func(client *http.Client) error { + _, err := fetchGraduateYJS1Exams(context.Background(), client, 0) + return err + }}, + {"scores", graduateScoreFixtures(`{}`, `{}`, `{}`), func(client *http.Client) error { + _, err := fetchGraduateYJS1ScoreReport(context.Background(), client) + return err + }}, + {"homework", graduateHomeworkFixtures(`{}`), func(client *http.Client) error { + _, err := fetchGraduateYJS1Homework(context.Background(), client) + return err + }}, + } + for _, flow := range flows { + for index, endpoint := range flow.fixtures { + failures := []string{"network", "read", "HTTP", "invalid JSON", "wrong type", "code"} + if endpoint.get { + failures = []string{"network", "read", "HTTP", "missing ID"} + } + for _, failure := range failures { + t.Run(flow.name+"/"+endpoint.url+"/"+failure, func(t *testing.T) { + fixtures := append([]graduateRequestFixture(nil), flow.fixtures[:index+1]...) + fixture := &fixtures[index] + sentinel := errors.New("graduate fixture failure") + switch failure { + case "network": + fixture.networkError = sentinel + case "read": + fixture.readError = sentinel + case "HTTP": + fixture.status = http.StatusServiceUnavailable + fixture.body = " unavailable " + case "invalid JSON": + fixture.body = "!" + case "wrong type": + fixture.body = `{"code":9}` + case "code": + fixture.body = `{"code":"9","msg":"fixture rejected"}` + case "missing ID": + fixture.body = `{"USERID" : "ignored"}` + } + err := flow.fetch(graduateFixtureClient(t, fixtures)) + if err == nil { + t.Fatal("expected request failure") + } + switch failure { + case "network", "read": + if !errors.Is(err, sentinel) { + t.Fatalf("lost failure cause: %v", err) + } + case "HTTP": + method := "POST" + if fixture.get { + method = "GET" + } + want := method + " " + fixture.url + " returned 503 Service Unavailable: unavailable" + if err.Error() != want { + t.Fatalf("HTTP error = %q, want %q", err, want) + } + case "invalid JSON": + var target *json.SyntaxError + if !errors.As(err, &target) { + t.Fatalf("JSON error = %T %v", err, err) + } + case "wrong type": + var target *json.UnmarshalTypeError + if !errors.As(err, &target) { + t.Fatalf("type error = %T %v", err, err) + } + case "code": + want := "graduate yjs1 request failed: fixture rejected" + if fixture.url == graduateTestScoreProfile { + want = "graduate score profile request failed: fixture rejected" + } + if err.Error() != want { + t.Fatalf("code error = %q, want %q", err, want) + } + case "missing ID": + if err.Error() != "could not resolve graduate student ID from "+graduateTestHomeworkApp { + t.Fatalf("ID error = %v", err) + } + } + }) + } + } } }