feat(types): add HOP entity tracking types (bd-7pwh)
- Add EntityRef type for structured entity references with URI support - Add Creator field to Issue for tracking who created work - Add Validation type and Validations field for proof-of-stake approvals - Fix RemoveDependency FK violation on external deps (bd-a3sj) - Include all new fields in content hash computation - Full test coverage for all new types 🤝 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -221,8 +221,13 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
|
|||||||
return fmt.Errorf("failed to record event: %w", err)
|
return fmt.Errorf("failed to record event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark both issues as dirty for incremental export
|
// Mark issues as dirty for incremental export
|
||||||
if err := markIssuesDirtyTx(ctx, tx, []string{issueID, dependsOnID}); err != nil {
|
// For external refs, only mark the source issue (target doesn't exist locally)
|
||||||
|
issueIDsToMark := []string{issueID}
|
||||||
|
if !strings.HasPrefix(dependsOnID, "external:") {
|
||||||
|
issueIDsToMark = append(issueIDsToMark, dependsOnID)
|
||||||
|
}
|
||||||
|
if err := markIssuesDirtyTx(ctx, tx, issueIDsToMark); err != nil {
|
||||||
return wrapDBError("mark issues dirty after removing dependency", err)
|
return wrapDBError("mark issues dirty after removing dependency", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1610,3 +1610,50 @@ func TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport(t *testin
|
|||||||
t.Error("Expected B to relate-to A")
|
t.Error("Expected B to relate-to A")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRemoveDependencyExternal verifies that removing an external dependency
|
||||||
|
// doesn't cause FK violation (bd-a3sj). External refs like external:project:capability
|
||||||
|
// don't exist in the issues table, so we must not mark them as dirty.
|
||||||
|
func TestRemoveDependencyExternal(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create an issue
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Issue with external dep",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||||
|
t.Fatalf("CreateIssue failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an external dependency
|
||||||
|
externalRef := "external:other-project:some-capability"
|
||||||
|
dep := &types.Dependency{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
DependsOnID: externalRef,
|
||||||
|
Type: types.DepBlocks,
|
||||||
|
}
|
||||||
|
if err := store.AddDependency(ctx, dep, "test-user"); err != nil {
|
||||||
|
t.Fatalf("AddDependency failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should NOT cause FK violation (the bug was marking external ref as dirty)
|
||||||
|
err := store.RemoveDependency(ctx, issue.ID, externalRef, "test-user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveDependency on external ref should succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dependency was actually removed
|
||||||
|
deps, err := store.GetDependencyRecords(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDependencyRecords failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(deps) != 0 {
|
||||||
|
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package types
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ type Issue struct {
|
|||||||
|
|
||||||
// Bonding fields (bd-rnnr): compound molecule lineage
|
// Bonding fields (bd-rnnr): compound molecule lineage
|
||||||
BondedFrom []BondRef `json:"bonded_from,omitempty"` // For compounds: constituent protos
|
BondedFrom []BondRef `json:"bonded_from,omitempty"` // For compounds: constituent protos
|
||||||
|
|
||||||
|
// HOP fields (bd-7pwh): entity tracking for CV chains
|
||||||
|
Creator *EntityRef `json:"creator,omitempty"` // Who created this issue (human, agent, or org)
|
||||||
|
Validations []Validation `json:"validations,omitempty"` // Who validated/approved this work
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputeContentHash creates a deterministic hash of the issue's content.
|
// ComputeContentHash creates a deterministic hash of the issue's content.
|
||||||
@@ -103,6 +108,38 @@ func (i *Issue) ComputeContentHash() string {
|
|||||||
h.Write([]byte(br.BondPoint))
|
h.Write([]byte(br.BondPoint))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
}
|
}
|
||||||
|
// Hash creator for HOP entity tracking (bd-m7ib)
|
||||||
|
if i.Creator != nil {
|
||||||
|
h.Write([]byte(i.Creator.Name))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(i.Creator.Platform))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(i.Creator.Org))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(i.Creator.ID))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
// Hash validations for HOP proof-of-stake (bd-du9h)
|
||||||
|
for _, v := range i.Validations {
|
||||||
|
if v.Validator != nil {
|
||||||
|
h.Write([]byte(v.Validator.Name))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(v.Validator.Platform))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(v.Validator.Org))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(v.Validator.ID))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
h.Write([]byte(v.Outcome))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(v.Timestamp.Format(time.RFC3339)))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
if v.Score != nil {
|
||||||
|
h.Write([]byte(fmt.Sprintf("%f", *v.Score)))
|
||||||
|
}
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
}
|
}
|
||||||
@@ -577,3 +614,118 @@ func (i *Issue) IsCompound() bool {
|
|||||||
func (i *Issue) GetConstituents() []BondRef {
|
func (i *Issue) GetConstituents() []BondRef {
|
||||||
return i.BondedFrom
|
return i.BondedFrom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntityRef is a structured reference to an entity (human, agent, or org).
|
||||||
|
// This is the foundation for HOP entity tracking and CV chains.
|
||||||
|
// Can be rendered as a URI: entity://hop/<platform>/<org>/<id>
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// ref := &EntityRef{
|
||||||
|
// Name: "polecat/Nux",
|
||||||
|
// Platform: "gastown",
|
||||||
|
// Org: "steveyegge",
|
||||||
|
// ID: "polecat-nux",
|
||||||
|
// }
|
||||||
|
// uri := ref.URI() // "entity://hop/gastown/steveyegge/polecat-nux"
|
||||||
|
type EntityRef struct {
|
||||||
|
// Name is the human-readable identifier (e.g., "polecat/Nux", "mayor")
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
|
||||||
|
// Platform identifies the execution context (e.g., "gastown", "github")
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
|
||||||
|
// Org identifies the organization (e.g., "steveyegge", "anthropics")
|
||||||
|
Org string `json:"org,omitempty"`
|
||||||
|
|
||||||
|
// ID is the unique identifier within the platform/org (e.g., "polecat-nux")
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if all fields are empty.
|
||||||
|
func (e *EntityRef) IsEmpty() bool {
|
||||||
|
if e == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return e.Name == "" && e.Platform == "" && e.Org == "" && e.ID == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// URI returns the entity as a HOP URI.
|
||||||
|
// Format: entity://hop/<platform>/<org>/<id>
|
||||||
|
// Returns empty string if Platform, Org, or ID is missing.
|
||||||
|
func (e *EntityRef) URI() string {
|
||||||
|
if e == nil || e.Platform == "" || e.Org == "" || e.ID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("entity://hop/%s/%s/%s", e.Platform, e.Org, e.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a human-readable representation.
|
||||||
|
// Prefers Name if set, otherwise returns URI or ID.
|
||||||
|
func (e *EntityRef) String() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Name != "" {
|
||||||
|
return e.Name
|
||||||
|
}
|
||||||
|
if uri := e.URI(); uri != "" {
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
return e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation records who validated/approved work completion.
|
||||||
|
// This is core to HOP's proof-of-stake concept - validators stake
|
||||||
|
// their reputation on approvals.
|
||||||
|
type Validation struct {
|
||||||
|
// Validator is who approved/rejected the work
|
||||||
|
Validator *EntityRef `json:"validator"`
|
||||||
|
|
||||||
|
// Outcome is the validation result: accepted, rejected, revision_requested
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
|
||||||
|
// Timestamp is when the validation occurred
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
|
||||||
|
// Score is an optional quality score (0.0-1.0)
|
||||||
|
Score *float32 `json:"score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation outcome constants
|
||||||
|
const (
|
||||||
|
ValidationAccepted = "accepted"
|
||||||
|
ValidationRejected = "rejected"
|
||||||
|
ValidationRevisionRequested = "revision_requested"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValidOutcome checks if the outcome is a known validation outcome.
|
||||||
|
func (v *Validation) IsValidOutcome() bool {
|
||||||
|
switch v.Outcome {
|
||||||
|
case ValidationAccepted, ValidationRejected, ValidationRevisionRequested:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEntityURI parses a HOP entity URI into an EntityRef.
|
||||||
|
// Format: entity://hop/<platform>/<org>/<id>
|
||||||
|
// Returns nil and error if the URI is invalid.
|
||||||
|
func ParseEntityURI(uri string) (*EntityRef, error) {
|
||||||
|
const prefix = "entity://hop/"
|
||||||
|
if !strings.HasPrefix(uri, prefix) {
|
||||||
|
return nil, fmt.Errorf("invalid entity URI: must start with %q", prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := uri[len(prefix):]
|
||||||
|
parts := strings.SplitN(rest, "/", 3)
|
||||||
|
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
||||||
|
return nil, fmt.Errorf("invalid entity URI: expected entity://hop/<platform>/<org>/<id>, got %q", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EntityRef{
|
||||||
|
Platform: parts[0],
|
||||||
|
Org: parts[1],
|
||||||
|
ID: parts[2],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -959,3 +959,311 @@ func TestSetDefaults(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntityRef tests (bd-nmch: HOP entity tracking foundation)
|
||||||
|
|
||||||
|
func TestEntityRefIsEmpty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref *EntityRef
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{"nil ref", nil, true},
|
||||||
|
{"empty ref", &EntityRef{}, true},
|
||||||
|
{"only name", &EntityRef{Name: "test"}, false},
|
||||||
|
{"only platform", &EntityRef{Platform: "gastown"}, false},
|
||||||
|
{"only org", &EntityRef{Org: "steveyegge"}, false},
|
||||||
|
{"only id", &EntityRef{ID: "polecat-nux"}, false},
|
||||||
|
{"full ref", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.ref.IsEmpty(); got != tt.expect {
|
||||||
|
t.Errorf("EntityRef.IsEmpty() = %v, want %v", got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityRefURI(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref *EntityRef
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{"nil ref", nil, ""},
|
||||||
|
{"empty ref", &EntityRef{}, ""},
|
||||||
|
{"missing platform", &EntityRef{Org: "steveyegge", ID: "polecat-nux"}, ""},
|
||||||
|
{"missing org", &EntityRef{Platform: "gastown", ID: "polecat-nux"}, ""},
|
||||||
|
{"missing id", &EntityRef{Platform: "gastown", Org: "steveyegge"}, ""},
|
||||||
|
{"full ref", &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"},
|
||||||
|
{"with name", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"},
|
||||||
|
{"github platform", &EntityRef{Platform: "github", Org: "anthropics", ID: "claude-code"}, "entity://hop/github/anthropics/claude-code"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.ref.URI(); got != tt.expect {
|
||||||
|
t.Errorf("EntityRef.URI() = %q, want %q", got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityRefString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref *EntityRef
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{"nil ref", nil, ""},
|
||||||
|
{"empty ref", &EntityRef{}, ""},
|
||||||
|
{"only name", &EntityRef{Name: "polecat/Nux"}, "polecat/Nux"},
|
||||||
|
{"full ref with name", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "polecat/Nux"},
|
||||||
|
{"full ref without name", &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"},
|
||||||
|
{"only id", &EntityRef{ID: "polecat-nux"}, "polecat-nux"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.ref.String(); got != tt.expect {
|
||||||
|
t.Errorf("EntityRef.String() = %q, want %q", got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEntityURI(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uri string
|
||||||
|
expect *EntityRef
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid URI",
|
||||||
|
uri: "entity://hop/gastown/steveyegge/polecat-nux",
|
||||||
|
expect: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github URI",
|
||||||
|
uri: "entity://hop/github/anthropics/claude-code",
|
||||||
|
expect: &EntityRef{Platform: "github", Org: "anthropics", ID: "claude-code"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id with slashes",
|
||||||
|
uri: "entity://hop/gastown/steveyegge/polecat/nux",
|
||||||
|
expect: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat/nux"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong prefix",
|
||||||
|
uri: "beads://hop/gastown/steveyegge/polecat-nux",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hop",
|
||||||
|
uri: "entity://gastown/steveyegge/polecat-nux",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too few parts",
|
||||||
|
uri: "entity://hop/gastown/steveyegge",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty platform",
|
||||||
|
uri: "entity://hop//steveyegge/polecat-nux",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty org",
|
||||||
|
uri: "entity://hop/gastown//polecat-nux",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty id",
|
||||||
|
uri: "entity://hop/gastown/steveyegge/",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
uri: "",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseEntityURI(tt.uri)
|
||||||
|
if tt.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseEntityURI(%q) expected error, got nil", tt.uri)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseEntityURI(%q) unexpected error: %v", tt.uri, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got.Platform != tt.expect.Platform || got.Org != tt.expect.Org || got.ID != tt.expect.ID {
|
||||||
|
t.Errorf("ParseEntityURI(%q) = %+v, want %+v", tt.uri, got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityRefRoundTrip(t *testing.T) {
|
||||||
|
// Test that URI() and ParseEntityURI() are inverses
|
||||||
|
original := &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}
|
||||||
|
uri := original.URI()
|
||||||
|
parsed, err := ParseEntityURI(uri)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEntityURI(%q) error: %v", uri, err)
|
||||||
|
}
|
||||||
|
if parsed.Platform != original.Platform || parsed.Org != original.Org || parsed.ID != original.ID {
|
||||||
|
t.Errorf("Round trip failed: got %+v, want %+v", parsed, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeContentHashWithCreator(t *testing.T) {
|
||||||
|
// Test that Creator field affects the content hash (bd-m7ib)
|
||||||
|
issue1 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
issue2 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
Creator: &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash1 := issue1.ComputeContentHash()
|
||||||
|
hash2 := issue2.ComputeContentHash()
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Error("Expected different hash when Creator is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same creator should produce same hash
|
||||||
|
issue3 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
Creator: &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash3 := issue3.ComputeContentHash()
|
||||||
|
if hash2 != hash3 {
|
||||||
|
t.Error("Expected same hash for identical Creator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation tests (bd-du9h: HOP proof-of-stake)
|
||||||
|
|
||||||
|
func TestValidationIsValidOutcome(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
outcome string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{ValidationAccepted, true},
|
||||||
|
{ValidationRejected, true},
|
||||||
|
{ValidationRevisionRequested, true},
|
||||||
|
{"unknown", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.outcome, func(t *testing.T) {
|
||||||
|
v := &Validation{Outcome: tt.outcome}
|
||||||
|
if got := v.IsValidOutcome(); got != tt.valid {
|
||||||
|
t.Errorf("Validation{Outcome: %q}.IsValidOutcome() = %v, want %v", tt.outcome, got, tt.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeContentHashWithValidations(t *testing.T) {
|
||||||
|
// Test that Validations field affects the content hash (bd-du9h)
|
||||||
|
ts := time.Date(2025, 12, 22, 10, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
issue1 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusClosed,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
ClosedAt: &ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
issue2 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusClosed,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
ClosedAt: &ts,
|
||||||
|
Validations: []Validation{
|
||||||
|
{
|
||||||
|
Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"},
|
||||||
|
Outcome: ValidationAccepted,
|
||||||
|
Timestamp: ts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash1 := issue1.ComputeContentHash()
|
||||||
|
hash2 := issue2.ComputeContentHash()
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Error("Expected different hash when Validations is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same validations should produce same hash
|
||||||
|
issue3 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusClosed,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
ClosedAt: &ts,
|
||||||
|
Validations: []Validation{
|
||||||
|
{
|
||||||
|
Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"},
|
||||||
|
Outcome: ValidationAccepted,
|
||||||
|
Timestamp: ts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash3 := issue3.ComputeContentHash()
|
||||||
|
if hash2 != hash3 {
|
||||||
|
t.Error("Expected same hash for identical Validations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with score
|
||||||
|
score := float32(0.95)
|
||||||
|
issue4 := Issue{
|
||||||
|
Title: "Test Issue",
|
||||||
|
Status: StatusClosed,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: TypeTask,
|
||||||
|
ClosedAt: &ts,
|
||||||
|
Validations: []Validation{
|
||||||
|
{
|
||||||
|
Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"},
|
||||||
|
Outcome: ValidationAccepted,
|
||||||
|
Timestamp: ts,
|
||||||
|
Score: &score,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash4 := issue4.ComputeContentHash()
|
||||||
|
if hash2 == hash4 {
|
||||||
|
t.Error("Expected different hash when Score is added")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user