Semantic-preserving canonicalization, same-byte write paths, structured content errors, and a dry-run-default migration_repair stop 404s caused by trimming project-143 hard-breaks and no-op lease hydrations. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
7.3 KiB
Go
226 lines
7.3 KiB
Go
package docrevision_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"chat-agent-system/internal/docrevision"
|
|
)
|
|
|
|
func TestCanonicalize_NFC_LF_TrailingNewline(t *testing.T) {
|
|
in := "a\r\n b \t\n"
|
|
out := docrevision.CanonicalizeMarkdown(in)
|
|
if out != "a\n b \t\n" {
|
|
t.Fatalf("must keep trailing spaces/tabs: got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestGoldenDiffFixtures(t *testing.T) {
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "golden_diff_cases.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fixture struct {
|
|
Cases []struct {
|
|
ID string `json:"id"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
ExpectKinds []string `json:"expect_kinds"`
|
|
} `json:"cases"`
|
|
}
|
|
if err := json.Unmarshal(raw, &fixture); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range fixture.Cases {
|
|
t.Run(c.ID, func(t *testing.T) {
|
|
before := docrevision.CanonicalizeMarkdown(c.Before)
|
|
after := docrevision.CanonicalizeMarkdown(c.After)
|
|
fb := docrevision.BuildBlockMap(before, nil)
|
|
ab := docrevision.BuildBlockMap(after, fb)
|
|
diff := docrevision.ComputeDiff(before, after, fb, ab)
|
|
if len(c.ExpectKinds) == 0 {
|
|
if len(diff.Hunks) != 0 {
|
|
t.Fatalf("noop expected empty hunks, got %+v", diff.Hunks)
|
|
}
|
|
return
|
|
}
|
|
found := map[string]bool{}
|
|
for _, h := range diff.Hunks {
|
|
found[h.Kind] = true
|
|
}
|
|
for _, k := range c.ExpectKinds {
|
|
if !found[k] {
|
|
t.Fatalf("expected kind %s in %+v", k, diff.Hunks)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContractTimeline_VersionRevisionSemantics(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
actor := docrevision.ActorSnapshot{DisplayName: "张工", Role: "author", Duty: "author"}
|
|
|
|
st, err := svc.OpenTemplateVersion(ctx, "doc_demo", "# V1\n\n模板正文\n", actor, 1, "workspaces/demo")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 {
|
|
t.Fatalf("T0 state: %+v", st)
|
|
}
|
|
|
|
r1, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n",
|
|
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
|
IdempotencyKey: "k1", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r1.State.CurrentVersionNo != 1 || r1.State.HeadRevisionNo != 1 || r1.State.ManualChangeCount != 1 {
|
|
t.Fatalf("T1: %+v", r1.State)
|
|
}
|
|
|
|
r2, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n李工再改\n",
|
|
ExpectedHeadSnapshot: &r1.State.HeadSnapshotID, ExpectedRevisionNo: &r1.State.HeadRevisionNo,
|
|
IdempotencyKey: "k2", ActorUserID: 11, ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "李工"}, LifecycleState: "drafting",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r2.State.CurrentVersionNo != 1 || r2.State.ManualChangeCount != 2 {
|
|
t.Fatalf("T2: %+v", r2.State)
|
|
}
|
|
|
|
reportRaw, _ := os.ReadFile(filepath.Join("testdata", "change_report_v1_valid.json"))
|
|
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
|
DocID: "doc_demo", RunID: "run-1", InputSnapshotID: r2.State.HeadSnapshotID,
|
|
OutputMarkdown: "# V2\n\n智能体新基线\n", ReportJSON: string(reportRaw),
|
|
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "squad-a"},
|
|
})
|
|
if err != nil && cand == nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cand.Status != docrevision.CandidateNeedsConfirmation {
|
|
t.Fatalf("high risk should need confirmation, got %s risk=%s", cand.Status, rep.RiskLevel)
|
|
}
|
|
_, err = svc.CommitAgentVersion(ctx, "doc_demo", cand.ID, docrevision.CommitAgentDecision{
|
|
Decision: "commit", Confirmed: false, ActorUserID: 10,
|
|
})
|
|
if err != docrevision.ErrNeedsConfirmation {
|
|
t.Fatalf("expected needs confirmation, got %v", err)
|
|
}
|
|
st3, err := svc.CommitAgentVersion(ctx, "doc_demo", cand.ID, docrevision.CommitAgentDecision{
|
|
Decision: "commit", Confirmed: true, ActorUserID: 10,
|
|
ExpectedInputSHA: r2.State.HeadSHA256,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st3.CurrentVersionNo != 2 || st3.HeadRevisionNo != 0 || st3.ManualChangeCount != 0 {
|
|
t.Fatalf("T3 after agent: %+v", st3)
|
|
}
|
|
|
|
overlay, err := svc.ManualOverlay(ctx, "doc_demo")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(overlay.Hunks) != 0 {
|
|
t.Fatalf("manual overlay should reset after agent baseline, got %+v", overlay)
|
|
}
|
|
|
|
r4, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
DocID: "doc_demo", Markdown: "# V2\n\n智能体新基线\n张工再改\n",
|
|
ExpectedHeadSnapshot: &st3.HeadSnapshotID, ExpectedRevisionNo: &st3.HeadRevisionNo,
|
|
IdempotencyKey: "k4", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r4.State.CurrentVersionNo != 2 || r4.State.ManualChangeCount != 1 {
|
|
t.Fatalf("T4: %+v", r4.State)
|
|
}
|
|
|
|
diff, err := svc.DiffVersions(ctx, "doc_demo", 1, 2, "final", "final")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(diff.Hunks) == 0 {
|
|
t.Fatal("V1 vs V2 should differ")
|
|
}
|
|
}
|
|
|
|
func TestBlockMapStableAcrossRebuild(t *testing.T) {
|
|
md := "## 需求\n\nREQ-PERF-007 温度精度 ±1℃\n"
|
|
a := docrevision.BuildBlockMap(md, nil)
|
|
b := docrevision.BuildBlockMap(md+"\n补充一句。\n", a)
|
|
found := false
|
|
for _, n := range b {
|
|
if n.BusinessKey == "REQ-PERF-007" {
|
|
for _, o := range a {
|
|
if o.BusinessKey == "REQ-PERF-007" && o.ID == n.ID {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("business key id not stable: a=%+v b=%+v", a, b)
|
|
}
|
|
}
|
|
|
|
func TestIdempotentHumanRevision(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
st, _ := svc.OpenTemplateVersion(ctx, "doc_idem", "# A\n", docrevision.ActorSnapshot{}, 1, "")
|
|
req := docrevision.HumanRevisionRequest{
|
|
DocID: "doc_idem", Markdown: "# A\n\nB\n",
|
|
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
|
IdempotencyKey: "same", ActorUserID: 1, LifecycleState: "drafting",
|
|
}
|
|
r1, err := svc.CommitHumanRevision(ctx, req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r2, err := svc.CommitHumanRevision(ctx, req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r1.Event.ID != r2.Event.ID || r1.State.HeadSnapshotID != r2.State.HeadSnapshotID {
|
|
t.Fatalf("idempotency broken")
|
|
}
|
|
}
|
|
|
|
func TestHeadConflict(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
st, _ := svc.OpenTemplateVersion(ctx, "doc_c", "# A\n", docrevision.ActorSnapshot{}, 1, "")
|
|
wrong := st.HeadSnapshotID + 99
|
|
_, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
DocID: "doc_c", Markdown: "# B\n", ExpectedHeadSnapshot: &wrong,
|
|
IdempotencyKey: "x", ActorUserID: 1, LifecycleState: "drafting",
|
|
})
|
|
if err != docrevision.ErrHeadConflict {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleLock(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
st, _ := svc.OpenTemplateVersion(ctx, "doc_l", "# A\n", docrevision.ActorSnapshot{}, 1, "")
|
|
svc.SetLifecycle("doc_l", "reviewing")
|
|
_, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
DocID: "doc_l", Markdown: "# B\n", ExpectedHeadSnapshot: &st.HeadSnapshotID,
|
|
IdempotencyKey: "y", ActorUserID: 1, LifecycleState: "reviewing",
|
|
})
|
|
if err != docrevision.ErrLifecycleLocked {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
}
|