Files
weichenandCursor c159786a70 fix(docgov): keep markdown hard-breaks and unify content hashes
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>
2026-09-14 02:02:53 +00:00

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)
}
}