diff --git a/docs/specifications/contracts.json b/docs/specifications/contracts.json new file mode 100644 index 0000000..ca5d291 --- /dev/null +++ b/docs/specifications/contracts.json @@ -0,0 +1,122 @@ +{ + "version": 1, + "owner": "CLI", + "requirements": [ + { + "id": "exam.cli-complete-list", + "scope": "private", + "rule": "Without an explicit page or limit, the exam command exhausts all server pages and preserves the total.", + "acceptance": { + "given": "A signed-in user has 104 exams across two REST pages.", + "when": "The real exam command prints JSON without pagination flags.", + "then": [ + "All 104 exams, including the last page, are printed once.", + "The combined total and page size are 104." + ], + "test": { + "file": "internal/cmd/workspace/exam_test.go", + "name": "TestSpecExamReadsEveryPageAndPreservesTotal/exam.cli-complete-list" + } + } + }, + { + "id": "exam.cli-explicit-pagination", + "scope": "private", + "rule": "Explicit page or limit flags select one REST page without auto-fetching further pages.", + "acceptance": { + "given": "An exam command includes page and/or limit plus date and semester filters.", + "when": "The real command requests its list and prints a table.", + "then": [ + "Exactly one request uses the selected page and pageSize." + ], + "test": { + "file": "internal/cmd/workspace/exam_test.go", + "name": "TestSpecExamExplicitPaginationAndFilters/exam.cli-explicit-pagination" + } + } + }, + { + "id": "exam.cli-page-failure", + "scope": "private", + "rule": "A failed later exam page fails the command without printing partial results.", + "acceptance": { + "given": "The first exam page succeeds and the second returns forbidden.", + "when": "The command tries to read the complete exam list.", + "then": [ + "The command returns an error.", + "No partial success is printed." + ], + "test": { + "file": "internal/cmd/workspace/exam_test.go", + "name": "TestSpecExamLaterPageFailureDoesNotPrintPartialSuccess/exam.cli-page-failure" + } + } + }, + { + "id": "exam.cli-positive-input", + "scope": "private", + "rule": "Explicit page, limit and semester identifiers must be positive; limit is at most 100.", + "acceptance": { + "given": "Exam flags contain zero, negative values or limit 101.", + "when": "The command validates explicit flags.", + "then": [ + "Invalid input fails before reading exams." + ], + "test": { + "file": "internal/cmd/workspace/exam_test.go", + "name": "TestSpecExamRejectsInvalidExplicitPaginationAndSemester/exam.cli-positive-input" + } + } + }, + { + "id": "room-map.cli-request", + "scope": "public", + "rule": "Room-map lookup uses the catalog room code route and preserves the returned room code.", + "acceptance": { + "given": "The server provides a highlighted map for B001.", + "when": "The production CLI room-map client requests B001.", + "then": [ + "GET /api/catalog/rooms/B001/map is used.", + "The returned code is B001." + ], + "test": { + "file": "internal/cmd/room/room_http_test.go", + "name": "TestSpecFetchRoomMapUsesRoomCodePath/room-map.cli-request" + } + } + }, + { + "id": "room-map.cli-input", + "scope": "public", + "rule": "Room codes are trimmed and empty codes are rejected.", + "acceptance": { + "given": "Room input has surrounding whitespace or only whitespace.", + "when": "The command normalizes the code.", + "then": [ + "Nonempty codes preserve their content after trimming.", + "Whitespace-only input fails." + ], + "test": { + "file": "internal/cmd/room/room_test.go", + "name": "TestSpecNormalizeRoomCodeTrimsAndRejectsEmpty/room-map.cli-input" + } + } + }, + { + "id": "exam.cli-semester-display", + "scope": "private", + "rule": "Each exam row in the table identifies its semester.", + "acceptance": { + "given": "A returned exam belongs to 2026 Fall.", + "when": "The actual exam command prints its table.", + "then": [ + "The table includes the exam semester." + ], + "test": { + "file": "internal/cmd/workspace/exam_test.go", + "name": "TestSpecExamSemesterDisplay/exam.cli-semester-display" + } + } + } + ] +} diff --git a/internal/cmd/room/room_http_test.go b/internal/cmd/room/room_http_test.go index f3965e7..11f5440 100644 --- a/internal/cmd/room/room_http_test.go +++ b/internal/cmd/room/room_http_test.go @@ -9,25 +9,28 @@ import ( "github.com/Life-USTC/CLI/internal/api" ) -func TestFetchRoomMapUsesRoomCodePath(t *testing.T) { - t.Setenv("LIFE_USTC_CONFIG_DIR", t.TempDir()) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/api/catalog/rooms/B001/map" { - t.Fatalf("request = %s %s", r.Method, r.URL) +func TestSpecFetchRoomMapUsesRoomCodePath(t *testing.T) { + t.Run("room-map.cli-request", func(t *testing.T) { + t.Setenv("LIFE_USTC_CONFIG_DIR", t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/catalog/rooms/B001/map" { + t.Fatalf("request = %s %s", r.Method, r.URL) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":"B001","status":"highlighted","building":"Main","floor":"1","imageUrl":"https://example.test/map.png"}`) + })) + defer server.Close() + client, err := api.NewTypedClient(server.URL, false) + if err != nil { + t.Fatal(err) } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"code":"B001","status":"highlighted","building":"Main","floor":"1","imageUrl":"https://example.test/map.png"}`) - })) - defer server.Close() - client, err := api.NewTypedClient(server.URL, false) - if err != nil { - t.Fatal(err) - } - data, err := fetchRoomMap(client, "B001") - if err != nil { - t.Fatal(err) - } - if got := data.(map[string]any)["code"]; got != "B001" { - t.Fatalf("room code = %#v", got) - } + data, err := fetchRoomMap(client, "B001") + if err != nil { + t.Fatal(err) + } + if got := data.(map[string]any)["code"]; got != "B001" { + t.Fatalf("room code = %#v", got) + } + + }) } diff --git a/internal/cmd/room/room_test.go b/internal/cmd/room/room_test.go index 7abf2ff..8fe9c7d 100644 --- a/internal/cmd/room/room_test.go +++ b/internal/cmd/room/room_test.go @@ -2,11 +2,14 @@ package room import "testing" -func TestNormalizeRoomCodeTrimsAndRejectsEmpty(t *testing.T) { - if got, err := normalizeRoomCode(" 西区 "); err != nil || got != "西区" { - t.Fatalf("normalizeRoomCode = %q, %v", got, err) - } - if _, err := normalizeRoomCode(" "); err == nil { - t.Fatal("normalizeRoomCode accepted empty code") - } +func TestSpecNormalizeRoomCodeTrimsAndRejectsEmpty(t *testing.T) { + t.Run("room-map.cli-input", func(t *testing.T) { + if got, err := normalizeRoomCode(" 西区 "); err != nil || got != "西区" { + t.Fatalf("normalizeRoomCode = %q, %v", got, err) + } + if _, err := normalizeRoomCode(" "); err == nil { + t.Fatal("normalizeRoomCode accepted empty code") + } + + }) } diff --git a/internal/cmd/workspace/exam_test.go b/internal/cmd/workspace/exam_test.go index 10f4741..be41f1b 100644 --- a/internal/cmd/workspace/exam_test.go +++ b/internal/cmd/workspace/exam_test.go @@ -63,143 +63,172 @@ func examPage(total, page, pageSize int) map[string]any { }} } -func TestExamReadsEveryPageAndPreservesTotal(t *testing.T) { - var mu sync.Mutex - pages := []int{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/workspace/exams" || r.Method != http.MethodGet { - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - http.NotFound(w, r) - return +func TestSpecExamReadsEveryPageAndPreservesTotal(t *testing.T) { + t.Run("exam.cli-complete-list", func(t *testing.T) { + var mu sync.Mutex + pages := []int{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/workspace/exams" || r.Method != http.MethodGet { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer exam-token" { + t.Error("missing exam bearer") + } + if r.URL.Query().Get("pageSize") != "100" { + t.Error("unexpected page size") + } + if r.URL.Query().Get("includeDateUnknown") != "true" { + t.Error("unknown dates must be included by default") + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + mu.Lock() + pages = append(pages, page) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(examPage(104, page, 100)) + })) + defer server.Close() + text, err := executeExam(t, server.URL, "json") + if err != nil { + t.Fatal(err) } - if r.Header.Get("Authorization") != "Bearer exam-token" { - t.Error("missing exam bearer") + var payload struct { + Data []map[string]any + Pagination struct { + Total int + PageSize int + } } - if r.URL.Query().Get("pageSize") != "100" { - t.Error("unexpected page size") + if err := json.Unmarshal([]byte(text), &payload); err != nil { + t.Fatal(err) } - if r.URL.Query().Get("includeDateUnknown") != "true" { - t.Error("unknown dates must be included by default") + if len(payload.Data) != 104 || payload.Pagination.Total != 104 || payload.Pagination.PageSize != 104 { + t.Fatalf("incomplete result: %d rows, pagination %+v", len(payload.Data), payload.Pagination) + } + if payload.Data[103]["id"] != float64(104) { + t.Fatal("last page missing") } - page, _ := strconv.Atoi(r.URL.Query().Get("page")) mu.Lock() - pages = append(pages, page) - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(examPage(104, page, 100)) - })) - defer server.Close() - text, err := executeExam(t, server.URL, "json") - if err != nil { - t.Fatal(err) - } - var payload struct { - Data []map[string]any - Pagination struct { - Total int - PageSize int + defer mu.Unlock() + if fmt.Sprint(pages) != "[1 2]" { + t.Fatalf("pages = %v", pages) } - } - if err := json.Unmarshal([]byte(text), &payload); err != nil { - t.Fatal(err) - } - if len(payload.Data) != 104 || payload.Pagination.Total != 104 || payload.Pagination.PageSize != 104 { - t.Fatalf("incomplete result: %d rows, pagination %+v", len(payload.Data), payload.Pagination) - } - if payload.Data[103]["id"] != float64(104) { - t.Fatal("last page missing") - } - mu.Lock() - defer mu.Unlock() - if fmt.Sprint(pages) != "[1 2]" { - t.Fatalf("pages = %v", pages) - } + + }) } -func TestExamExplicitPaginationAndFilters(t *testing.T) { - for _, tc := range []struct { - name string - args []string - page, pageSize, total int - }{ - {"page", []string{"--page", "2"}, 2, 20, 24}, - {"limit", []string{"--limit", "2"}, 1, 2, 4}, - {"both", []string{"--page", "2", "--limit", "2"}, 2, 2, 4}, - } { - t.Run(tc.name, func(t *testing.T) { - requests := 0 - var mu sync.Mutex - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - requests++ - mu.Unlock() - if r.URL.Path != "/api/workspace/exams" { - t.Error("exam list used the wrong endpoint") - } - for key, want := range map[string]string{"semesterId": "7", "dateFrom": "2026-09-01", "dateTo": "2026-09-30", "includeDateUnknown": "false"} { - if r.URL.Query().Get(key) != want { - t.Errorf("%s = %q", key, r.URL.Query().Get(key)) +func TestSpecExamExplicitPaginationAndFilters(t *testing.T) { + t.Run("exam.cli-explicit-pagination", func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + page, pageSize, total int + }{ + {"page", []string{"--page", "2"}, 2, 20, 24}, + {"limit", []string{"--limit", "2"}, 1, 2, 4}, + {"both", []string{"--page", "2", "--limit", "2"}, 2, 2, 4}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + var mu sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests++ + mu.Unlock() + if r.URL.Path != "/api/workspace/exams" { + t.Error("exam list used the wrong endpoint") } + for key, want := range map[string]string{"semesterId": "7", "dateFrom": "2026-09-01", "dateTo": "2026-09-30", "includeDateUnknown": "false"} { + if r.URL.Query().Get(key) != want { + t.Errorf("%s = %q", key, r.URL.Query().Get(key)) + } + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page == 0 { + page = 1 + } + size, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) + if size == 0 { + size = 20 + } + if page != tc.page || size != tc.pageSize { + t.Errorf("page/size = %d/%d", page, size) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(examPage(tc.total, page, size)) + })) + defer server.Close() + args := append(tc.args, "--semester-id", "7", "--date-from", "2026-09-01", "--date-to", "2026-09-30", "--include-date-unknown=false") + text, err := executeExam(t, server.URL, "table", args...) + if err != nil { + t.Fatal(err) } - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page == 0 { - page = 1 - } - size, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) - if size == 0 { - size = 20 + if !strings.Contains(text, "2026 Fall") || !strings.Contains(text, fmt.Sprintf("of %d", tc.total)) { + t.Fatalf("missing semester or true total in %q", text) } - if page != tc.page || size != tc.pageSize { - t.Errorf("page/size = %d/%d", page, size) + mu.Lock() + defer mu.Unlock() + if requests != 1 { + t.Fatalf("explicit pagination made %d requests", requests) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(examPage(tc.total, page, size)) - })) - defer server.Close() - args := append(tc.args, "--semester-id", "7", "--date-from", "2026-09-01", "--date-to", "2026-09-30", "--include-date-unknown=false") - text, err := executeExam(t, server.URL, "table", args...) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(text, "2026 Fall") || !strings.Contains(text, fmt.Sprintf("of %d", tc.total)) { - t.Fatalf("missing semester or true total in %q", text) - } - mu.Lock() - defer mu.Unlock() - if requests != 1 { - t.Fatalf("explicit pagination made %d requests", requests) + }) + } + + }) +} + +func TestSpecExamLaterPageFailureDoesNotPrintPartialSuccess(t *testing.T) { + t.Run("exam.cli-page-failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("page") == "1" { + _ = json.NewEncoder(w).Encode(examPage(104, 1, 100)) + return } - }) - } + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"error":"exam access revoked"}`) + })) + defer server.Close() + text, err := executeExam(t, server.URL, "json") + if err == nil { + t.Fatal("later page failure was ignored") + } + if strings.TrimSpace(text) != "" { + t.Fatalf("partial output printed: %s", text) + } + + }) } -func TestExamLaterPageFailureDoesNotPrintPartialSuccess(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Query().Get("page") == "1" { - _ = json.NewEncoder(w).Encode(examPage(104, 1, 100)) - return +func TestSpecExamRejectsInvalidExplicitPaginationAndSemester(t *testing.T) { + t.Run("exam.cli-positive-input", func(t *testing.T) { + for _, args := range [][]string{{"--page", "0"}, {"--limit", "0"}, {"--semester-id", "0"}, {"--page", "-1"}, {"--limit", "101"}, {"--semester-id", "-1"}} { + cmd := newCmdExam() + cmd.SilenceErrors, cmd.SilenceUsage = true, true + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil { + t.Fatalf("invalid args accepted: %v", args) + } } - w.WriteHeader(http.StatusForbidden) - _, _ = io.WriteString(w, `{"error":"exam access revoked"}`) - })) - defer server.Close() - text, err := executeExam(t, server.URL, "json") - if err == nil { - t.Fatal("later page failure was ignored") - } - if strings.TrimSpace(text) != "" { - t.Fatalf("partial output printed: %s", text) - } + + }) } -func TestExamRejectsInvalidExplicitPaginationAndSemester(t *testing.T) { - for _, args := range [][]string{{"--page", "0"}, {"--limit", "0"}, {"--semester-id", "0"}, {"--page", "-1"}, {"--limit", "101"}, {"--semester-id", "-1"}} { - cmd := newCmdExam() - cmd.SilenceErrors, cmd.SilenceUsage = true, true - cmd.SetArgs(args) - if err := cmd.Execute(); err == nil { - t.Fatalf("invalid args accepted: %v", args) +func TestSpecExamSemesterDisplay(t *testing.T) { + t.Run("exam.cli-semester-display", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(examPage(1, 1, 20)) + })) + defer server.Close() + text, err := executeExam(t, server.URL, "table", "--page", "1") + if err != nil { + t.Fatal(err) } - } + if !strings.Contains(text, "2026 Fall") { + t.Fatalf("semester omitted: %s", text) + } + }) } diff --git a/internal/specification/contracts_test.go b/internal/specification/contracts_test.go new file mode 100644 index 0000000..2bcb523 --- /dev/null +++ b/internal/specification/contracts_test.go @@ -0,0 +1,160 @@ +package specification + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +type contractDocument struct { + Version int `json:"version"` + Owner string `json:"owner"` + Requirements []struct { + ID string `json:"id"` + Scope string `json:"scope"` + Rule string `json:"rule"` + Acceptance struct { + Given string `json:"given"` + When string `json:"when"` + Then []string `json:"then"` + Test struct { + File string `json:"file"` + Name string `json:"name"` + } `json:"test"` + } `json:"acceptance"` + } `json:"requirements"` +} + +// Native Go names include the TestSpec function and its one literal requirement +// subtest. This checks both directions; go test ./... executes the bound behavior. +func TestSpecificationBindings(t *testing.T) { + root := filepath.Join("..", "..") + file, err := os.Open(filepath.Join(root, "docs/specifications/contracts.json")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + var document contractDocument + if err := decoder.Decode(&document); err != nil { + t.Fatal(err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + t.Fatal("expected one JSON document") + } + if document.Version != 1 || document.Owner == "" || len(document.Requirements) == 0 { + t.Fatal("invalid specification header") + } + tests := map[string]string{} + err = filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, "_test.go") { + return nil + } + source, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + for _, declaration := range source.Decls { + fn, ok := declaration.(*ast.FuncDecl) + if !ok || !strings.HasPrefix(fn.Name.Name, "TestSpec") || fn.Name.Name == "TestSpecificationBindings" { + continue + } + if fn.Body == nil || len(fn.Body.List) != 1 { + t.Errorf("%s must contain one literal requirement subtest", fn.Name.Name) + continue + } + statement, ok := fn.Body.List[0].(*ast.ExprStmt) + if !ok { + t.Errorf("%s has no subtest", fn.Name.Name) + continue + } + call, ok := statement.X.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + t.Errorf("%s has no subtest", fn.Name.Name) + continue + } + method, ok := call.Fun.(*ast.SelectorExpr) + if !ok || method.Sel.Name != "Run" { + t.Errorf("%s has no Run call", fn.Name.Name) + continue + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + t.Errorf("%s needs a literal ID", fn.Name.Name) + continue + } + id, err := strconv.Unquote(literal.Value) + if err != nil { + return err + } + name := fn.Name.Name + "/" + id + if _, exists := tests[name]; exists { + t.Errorf("duplicate canonical test %s", name) + } + tests[name] = filepath.ToSlash(relative) + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + method, ok := call.Fun.(*ast.SelectorExpr) + if ok && (method.Sel.Name == "Skip" || method.Sel.Name == "Skipf" || method.Sel.Name == "SkipNow") { + t.Errorf("canonical test %s may not skip", name) + } + return true + }) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + ids, owners := map[string]bool{}, map[string]bool{} + for _, requirement := range document.Requirements { + acceptance := requirement.Acceptance + if requirement.ID == "" || ids[requirement.ID] { + t.Errorf("invalid or repeated requirement ID %q", requirement.ID) + } + ids[requirement.ID] = true + if requirement.Scope != "public" && requirement.Scope != "private" { + t.Errorf("invalid scope for %s", requirement.ID) + } + if strings.TrimSpace(requirement.Rule) == "" || strings.TrimSpace(acceptance.Given) == "" || strings.TrimSpace(acceptance.When) == "" || len(acceptance.Then) == 0 { + t.Errorf("incomplete acceptance for %s", requirement.ID) + } + for _, outcome := range acceptance.Then { + if strings.TrimSpace(outcome) == "" { + t.Errorf("empty outcome for %s", requirement.ID) + } + } + binding := acceptance.Test + if !strings.HasSuffix(binding.Name, "/"+requirement.ID) || tests[binding.Name] != binding.File || binding.File == "" { + t.Errorf("missing canonical test for %s: %+v", requirement.ID, binding) + } + if owners[binding.Name] { + t.Errorf("test %s has multiple owners", binding.Name) + } + owners[binding.Name] = true + } + for name := range tests { + if !owners[name] { + t.Errorf("orphan canonical test %s", name) + } + } +}