✨ feat(tui): 状态评论改用隐藏格式读写,已知字段改从 schema 派生
This commit is contained in:
1 parent
9d1bb06f38
commit
a23c88e812
5 files changed
+423
-50
No files matched your search
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user