package issue_test import ( "bytes" "context" "encoding/json" "os" "strings" "testing" "superwork-tui/internal/gitea" "superwork-tui/internal/issue" ) // sourceSchemaPath is the canonical state schema shared with the VS Code // extension and the spx CLI, relative to this package directory. const sourceSchemaPath = "../../../vscode/schemas/state-json.schema.json" func TestStateSchemaCopyInSync(t *testing.T) { src, err := os.ReadFile(sourceSchemaPath) if err != nil { t.Fatalf("read source schema: %v", err) } local, err := os.ReadFile("state-json.schema.json") if err != nil { t.Fatalf("read embedded schema copy: %v (run `go generate ./internal/issue`)", err) } if !bytes.Equal(src, local) { t.Fatal("state-json.schema.json is out of date; run `go generate ./internal/issue`") } } func TestParseStateComment_RecognizesEverySchemaField(t *testing.T) { raw, err := os.ReadFile(sourceSchemaPath) if err != nil { t.Fatalf("read source schema: %v", err) } var schema struct { Properties map[string]json.RawMessage `json:"properties"` } if err := json.Unmarshal(raw, &schema); err != nil { t.Fatalf("parse source schema: %v", err) } if len(schema.Properties) == 0 { t.Fatal("source schema has no properties") } for field := range schema.Properties { body := `{"` + field + `":"x"}` if _, ok := issue.ParseStateComment(body); !ok { t.Errorf("field %q not recognised as a state field", field) } } } func TestParseStateComment_AcceptedForms(t *testing.T) { cases := map[string]string{ "hidden": "", "hidden padded": "\n \n", "hidden single line": ``, "legacy youtrack": "\n{\"column\":\"review\",\"pr\":\"7\"}", "bare json": `{"column":"review","pr":"7"}`, "bare json padded": " {\"column\":\"review\",\"pr\":\"7\"}\n", "hidden escaped html": "", } for name, body := range cases { t.Run(name, func(t *testing.T) { state, ok := issue.ParseStateComment(body) if !ok { t.Fatalf("expected %q to parse as a state comment", body) } if state["column"] != "review" || state["pr"] != "7" { t.Errorf("got %v, want column=review pr=7", state) } }) } } func TestParseStateComment_Rejected(t *testing.T) { cases := map[string]string{ "empty": "", "whitespace": " \n ", "plain text": "LGTM, merging", "unknown fields only": `{"foo":"bar"}`, "array": `[{"column":"todo"}]`, "scalar": `"column"`, "hidden unknown only": "", "hidden broken json": "", "hidden unterminated": "\n{\"column\":\"todo\"}", "legacy marker no obj": "\nnot json", } for name, body := range cases { t.Run(name, func(t *testing.T) { if state, ok := issue.ParseStateComment(body); ok { t.Errorf("expected %q to be rejected, got %v", body, state) } }) } } func TestFormatStateComment_HiddenAndRoundTrips(t *testing.T) { in := map[string]any{ "column": "review", "branch": "feat/a-->b&d", } body, err := issue.FormatStateComment(in) if err != nil { t.Fatalf("FormatStateComment: %v", err) } const open, close = "" if !strings.HasPrefix(body, open) || !strings.HasSuffix(body, close) { t.Fatalf("body %q is not wrapped as ", body) } payload := strings.TrimSuffix(strings.TrimPrefix(body, open), close) if strings.ContainsAny(payload, "<>&") { t.Errorf("payload %q must escape < > &", payload) } if strings.Contains(payload, "\n") { t.Errorf("payload %q must be compact single-line JSON", payload) } if strings.Count(body, "-->") != 1 { t.Errorf("body %q must contain exactly one -->", body) } out, ok := issue.ParseStateComment(body) if !ok { t.Fatalf("formatted body %q did not parse back", body) } if out["column"] != "review" || out["branch"] != "feat/a-->b&d" { t.Errorf("round trip got %v, want %v", out, in) } } func TestExtractStateJSON_NewestValidWinsAcrossFormats(t *testing.T) { comments := []gitea.Comment{ {Body: `{"column":"todo","branch":"feat/old"}`}, {Body: ""}, {Body: ""}, {Body: "just chatter"}, } state := issue.ExtractStateJSON(comments) if state["column"] != "review" || state["branch"] != "feat/new" { t.Errorf("got %v, want the hidden review blob", state) } comments = append(comments, gitea.Comment{Body: "\n{\"column\":\"done\"}"}) state = issue.ExtractStateJSON(comments) if state["column"] != "done" { t.Errorf("got %v, want the newer legacy YouTrack blob", state) } } func TestMergeStateJSON_PostsHiddenFormatKeepingFields(t *testing.T) { client := &fakeClient{ issueComments: map[int][]gitea.Comment{ 5: {{Body: `{"column":"todo","prototypeSessionId":"proto-1","handoffBundleKey":"k"}`}}, }, } err := issue.MergeStateJSON(context.Background(), client, "owner", "repo", 5, map[string]any{"column": "review"}) if err != nil { t.Fatalf("MergeStateJSON: %v", err) } if len(client.posted) != 1 { t.Fatalf("expected 1 posted comment, got %d", len(client.posted)) } body := client.posted[0] if !strings.HasPrefix(body, "