Files

177 lines
5.9 KiB
Go

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": "<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n-->",
"hidden padded": "\n <!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\"}\n--> \n",
"hidden single line": `<!-- spx-state {"column":"review","pr":"7"} -->`,
"legacy youtrack": "<!--spx-state-->\n{\"column\":\"review\",\"pr\":\"7\"}",
"bare json": `{"column":"review","pr":"7"}`,
"bare json padded": " {\"column\":\"review\",\"pr\":\"7\"}\n",
"hidden escaped html": "<!-- spx-state\n{\"column\":\"review\",\"pr\":\"7\",\"branch\":\"a\\u003c\\u003e\\u0026b\"}\n-->",
}
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": "<!-- spx-state\n{\"foo\":\"bar\"}\n-->",
"hidden broken json": "<!-- spx-state\n{\"column\":\n-->",
"hidden unterminated": "<!-- spx-state\n{\"column\":\"todo\"}",
"other html comment": "<!-- spx:review=1 -->\n{\"column\":\"todo\"}",
"legacy marker no obj": "<!--spx-state-->\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<c>&d",
}
body, err := issue.FormatStateComment(in)
if err != nil {
t.Fatalf("FormatStateComment: %v", err)
}
const open, close = "<!-- spx-state\n", "\n-->"
if !strings.HasPrefix(body, open) || !strings.HasSuffix(body, close) {
t.Fatalf("body %q is not wrapped as <!-- spx-state\\n...\\n-->", 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<c>&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: "<!-- spx-state\n{\"column\":\"review\",\"branch\":\"feat/new\"}\n-->"},
{Body: "<!-- spx-state\n{\"foo\":\"bar\"}\n-->"},
{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: "<!--spx-state-->\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, "<!-- spx-state\n") {
t.Errorf("posted body %q is not in the hidden format", body)
}
state, ok := issue.ParseStateComment(body)
if !ok {
t.Fatalf("posted body %q is not a state comment", body)
}
if state["column"] != "review" || state["prototypeSessionId"] != "proto-1" || state["handoffBundleKey"] != "k" {
t.Errorf("merged state %v lost or misapplied fields", state)
}
}