Files

372 lines
12 KiB
Go

package traceimpact
import (
"context"
"strings"
"testing"
)
func TestExtract_UntypedIsCandidateOnly(t *testing.T) {
keys := ExtractKeys("UR-001 对应 RS-002\n另见 AB-9")
explicit, untyped := 0, 0
for _, k := range keys {
if k.Explicit {
explicit++
} else {
untyped++
}
}
if explicit != 2 || untyped != 1 {
t.Fatalf("explicit=%d untyped=%d keys=%v", explicit, untyped, keys)
}
}
func TestWalkReverse_DepthAndCycle(t *testing.T) {
edges := []Edge{
{ID: 1, FromItemID: 2, ToItemID: 1, RelationType: RelationRefines, ReviewState: ReviewConfirmed},
{ID: 2, FromItemID: 3, ToItemID: 2, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 3, FromItemID: 4, ToItemID: 3, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 4, FromItemID: 1, ToItemID: 4, RelationType: RelationDependsOn, ReviewState: ReviewConfirmed},
{ID: 5, FromItemID: 9, ToItemID: 1, RelationType: RelationDependsOn, ReviewState: ReviewCandidate},
}
hits, truncated := WalkReverse(edges, []int64{1}, 3, 50)
if truncated {
t.Fatal("did not expect truncation")
}
got := map[int64]int{}
for _, h := range hits {
got[h.ItemID] = h.Depth
}
if got[2] != 1 || got[3] != 2 || got[4] != 3 {
t.Fatalf("depths = %#v", got)
}
if _, ok := got[9]; ok {
t.Fatal("candidate edge must not be followed")
}
if _, ok := got[1]; ok {
t.Fatal("cycle must not revisit the start as a hit")
}
}
func TestAnalyze_PendingIsNotNoImpact(t *testing.T) {
svc := NewMemoryService()
rep, err := svc.Analyze(t.Context(), 7, DocRef{DocID: "doc-a", Markdown: "UR-001"}, nil)
if err != nil {
t.Fatal(err)
}
if rep.CoverageStatus != ImpactIndexPending {
t.Fatalf("status %s", rep.CoverageStatus)
}
if strings.Contains(rep.Summary, "未发现") {
t.Fatalf("pending must not look like no impact: %s", rep.Summary)
}
}
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
var notices int
svc.SetRecheckHook(func(_ context.Context, mark RecheckMark) {
notices++
if mark.StageKey != "V0.5" || mark.ID <= 0 {
t.Fatalf("mark %+v", mark)
}
})
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "down", Markdown: "RS-010 细化 UR-001"}); err != nil {
t.Fatal(err)
}
rep, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
}})
if err != nil {
t.Fatal(err)
}
if rep.CoverageStatus != ImpactConfirmed {
t.Fatalf("status %s summary %s impacts %#v", rep.CoverageStatus, rep.Summary, rep.Impacts)
}
need, err := svc.NeedsRecheckAck(ctx, 7, "V0.5")
if err != nil || !need {
t.Fatalf("need ack %v %v", need, err)
}
if err := svc.AcknowledgeRecheck(ctx, 7, "V0.5", 3); err != nil {
t.Fatal(err)
}
need, err = svc.NeedsRecheckAck(ctx, 7, "V0.5")
if err != nil || !need {
t.Fatal("ack must not clear the mark")
}
marks, err := svc.store.ListOpenMarks(ctx, 7, "V0.5")
if err != nil || len(marks) != 1 {
t.Fatal(err)
}
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
t.Fatal("auto clear must fail")
}
if notices != 1 {
t.Fatalf("recheck hook calls=%d", notices)
}
if _, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
}}); err != nil {
t.Fatal(err)
}
if notices != 1 {
t.Fatalf("repeat analyze must not notify again, calls=%d", notices)
}
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
t.Fatal(err)
}
need, _ = svc.NeedsRecheckAck(ctx, 7, "V0.5")
if need {
t.Fatal("explicit no_change should clear")
}
}
func TestDecide_DownstreamAuthorOnly(t *testing.T) {
if err := CanDecide(Actor{UserID: 2, Creator: true}, "down", 9); err == nil {
t.Fatal("creator must not override an existing author")
}
if err := CanDecide(Actor{UserID: 9}, "down", 9); err != nil {
t.Fatal(err)
}
if err := CanDecide(Actor{UserID: 1, Creator: true}, "down", 0); err != nil {
t.Fatal(err)
}
}
type fakeReopen struct{ called bool }
func (f *fakeReopen) ReopenCompletedStage(_ context.Context, _ int64, stageKey string, docTaskKeys []string, _ int64) error {
f.called = true
if stageKey != "V0.1" || len(docTaskKeys) != 1 {
return fmtError("bad reopen")
}
return nil
}
type fmtError string
func (e fmtError) Error() string { return string(e) }
func TestReopen_CreatorAppliesWithoutPausingHook(t *testing.T) {
svc := NewMemoryService()
hook := &fakeReopen{}
svc.SetStageReopener(hook)
req, err := svc.RequestReopen(t.Context(), ReopenRequest{
ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"},
Reason: "新材料推翻了摸底", RequestedBy: 4,
}, true)
if err != nil {
t.Fatal(err)
}
if req.Status != ReopenApproved || !hook.called {
t.Fatalf("status %s called %v", req.Status, hook.called)
}
}
func TestReopen_MemberStaysPending(t *testing.T) {
svc := NewMemoryService()
req, err := svc.RequestReopen(t.Context(), ReopenRequest{
ProjectID: 1, InstanceID: 2, StageKey: "V0.1", DocTasks: []string{"bd_intake"},
Reason: "需要重编", RequestedBy: 8,
}, false)
if err != nil {
t.Fatal(err)
}
if err != nil || req.Status != ReopenPending {
t.Fatalf("status %s err %v", req.Status, err)
}
}
func TestIndex_SameSnapshotDoesNotDuplicateRevision(t *testing.T) {
svc := NewMemoryService()
doc := DocRef{DocID: "doc_proj1_intake", Markdown: "UR-001 用户需求", SnapshotID: 7, Visible: true}
if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(t.Context(), 1, doc); err != nil {
t.Fatal(err)
}
items, err := svc.ListItems(t.Context(), 1)
if err != nil || len(items) != 1 {
t.Fatalf("items %+v err %v", items, err)
}
n, err := svc.store.RevisionCount(t.Context(), items[0].ID)
if err != nil || n != 1 {
t.Fatalf("revisions %d err %v", n, err)
}
again, err := svc.IndexDocument(t.Context(), 2, DocRef{DocID: "doc_proj2_intake", Markdown: "UR-001 另一项目", Visible: true})
if err != nil {
t.Fatal(err)
}
_ = again
other, err := svc.ListItems(t.Context(), 1)
if err != nil || len(other) != 1 || other[0].ProjectID != 1 {
t.Fatalf("cross project leaked %+v", other)
}
}
func TestRedactItems_HidesInvisibleStatements(t *testing.T) {
items := []Item{
{BusinessKey: "UR-001", Statement: "可见标题", SourceDocID: "doc_proj1_intake"},
{BusinessKey: "REQ-9", Statement: "秘密标题", SourceDocID: "doc_proj1_secret"},
}
out, hidden := RedactItems(items, func(docID string) bool { return docID == "doc_proj1_intake" })
if hidden != 1 || len(out) != 1 || out[0].Statement != "可见标题" {
t.Fatalf("out %+v hidden %d", out, hidden)
}
blob := strings.Join([]string{out[0].BusinessKey, out[0].Statement}, " ")
if strings.Contains(blob, "秘密") {
t.Fatal(blob)
}
}
func TestMayRequestReopen_DutyHolderOnly(t *testing.T) {
seats := []DutySeat{{TaskKey: "intake", AuthorID: 8, ReviewerID: 2, ApproverID: 3}}
if !MayRequestReopen(8, false, []string{"intake"}, seats) {
t.Fatal("author")
}
if !MayRequestReopen(1, true, []string{"intake"}, nil) {
t.Fatal("creator")
}
if MayRequestReopen(99, false, []string{"intake"}, seats) {
t.Fatal("stranger")
}
}
func TestPropose_UnconfirmedDoesNotStore(t *testing.T) {
svc := NewMemoryService()
if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", false); err != ErrNeedsConfirm {
t.Fatalf("err %v", err)
}
if _, err := svc.ProposeRevision(t.Context(), 1, 4, "改写 UR-001", true); err != nil {
t.Fatal(err)
}
}
func TestReplayDecisions_RejectSurvivesProjectionReset(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "down", Markdown: "RS-010 细化需求"}); err != nil {
t.Fatal(err)
}
edge, err := svc.UpsertModelCandidate(ctx, 7, "RS-010", "UR-001", "down")
if err != nil {
t.Fatal(err)
}
if err := svc.DecideCandidate(ctx, edge.ID, 9, false, "down", 9, DecisionReject, "误匹配"); err != nil {
t.Fatal(err)
}
if err := svc.store.SetEdgeReview(ctx, edge.ID, ReviewCandidate); err != nil {
t.Fatal(err)
}
got, err := svc.store.GetEdge(ctx, edge.ID)
if err != nil || got.ReviewState != ReviewCandidate {
t.Fatalf("reset failed: %+v err=%v", got, err)
}
n, err := svc.ReplayDecisions(ctx, 7)
if err != nil || n != 1 {
t.Fatalf("replay applied=%d err=%v", n, err)
}
got, err = svc.store.GetEdge(ctx, edge.ID)
if err != nil || got.ReviewState != ReviewRejected {
t.Fatalf("after replay %+v err=%v", got, err)
}
hits, _ := WalkReverse([]Edge{got}, []int64{got.ToItemID}, 3, 50)
for _, h := range hits {
if h.ItemID == got.FromItemID {
t.Fatal("rejected edge must not be walked")
}
}
}
func TestAnalyze_BoundedMultiHopDepth3(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
docs := []struct {
id, md string
}{
{"d1", "UR-001 根需求"},
{"d2", "RS-002 细化 UR-001"},
{"d3", "REQ-003 依赖 RS-002"},
{"d4", "TC-004 验证 REQ-003"},
{"d5", "TC-005 验证 TC-004"},
}
for _, d := range docs {
if _, err := svc.IndexDocument(ctx, 3, DocRef{DocID: d.id, Markdown: d.md}); err != nil {
t.Fatal(err)
}
}
// IndexDocument only confirms same-line pairs. d5→d4 is not same-line with UR, so chain
// depth-4 must be explicit confirmed edges built for the walk.
items, err := svc.ListItems(ctx, 3)
if err != nil {
t.Fatal(err)
}
byKey := map[string]Item{}
for _, it := range items {
byKey[it.BusinessKey] = it
}
chain := [][2]string{{"RS-002", "UR-001"}, {"REQ-003", "RS-002"}, {"TC-004", "REQ-003"}, {"TC-005", "TC-004"}}
for _, pair := range chain {
if _, err := svc.store.UpsertEdge(ctx, Edge{
ProjectID: 3, FromItemID: byKey[pair[0]].ID, ToItemID: byKey[pair[1]].ID,
RelationType: RelationDependsOn, Provenance: ProvenanceExplicit, ReviewState: ReviewConfirmed,
FromKey: pair[0], ToKey: pair[1],
}); err != nil {
t.Fatal(err)
}
}
rep, err := svc.Analyze(ctx, 3, DocRef{DocID: "d1", Markdown: "UR-001 根需求变更"}, []DocRef{
{DocID: "d2", Visible: true}, {DocID: "d3", Visible: true},
{DocID: "d4", Visible: true}, {DocID: "d5", Visible: true},
})
if err != nil {
t.Fatal(err)
}
depths := map[string]int{}
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactConfirmed {
depths[imp.ItemKey] = imp.Depth
}
}
if depths["RS-002"] != 1 || depths["REQ-003"] != 2 || depths["TC-004"] != 3 {
t.Fatalf("expected depth 1..3, got %#v impacts=%#v", depths, rep.Impacts)
}
if _, ok := depths["TC-005"]; ok {
t.Fatalf("depth-4 TC-005 must not appear in confirmed impacts: %#v", depths)
}
}
func TestListCandidateEdges_OnlyUndecided(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
if _, err := svc.IndexDocument(ctx, 1, DocRef{DocID: "a", Markdown: "UR-001 x"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 1, DocRef{DocID: "b", Markdown: "RS-002 y"}); err != nil {
t.Fatal(err)
}
edge, err := svc.UpsertModelCandidate(ctx, 1, "RS-002", "UR-001", "b")
if err != nil {
t.Fatal(err)
}
cands, err := svc.ListCandidateEdges(ctx, 1)
if err != nil || len(cands) != 1 || cands[0].FromKey != "RS-002" {
t.Fatalf("cands=%#v err=%v", cands, err)
}
if err := svc.DecideCandidate(ctx, edge.ID, 1, true, "b", 0, DecisionConfirm, "ok"); err != nil {
t.Fatal(err)
}
cands, err = svc.ListCandidateEdges(ctx, 1)
if err != nil || len(cands) != 0 {
t.Fatalf("after confirm cands=%#v err=%v", cands, err)
}
}