test(agentcompat): define integration contracts

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:39:48 +00:00
co-authored by naiba/CloudCode
parent 391f622c75
commit fcf58b0b4b
6 changed files with 583 additions and 0 deletions
@@ -0,0 +1,51 @@
package contract
import (
"errors"
"time"
)
const (
ResourceWarmupRuns = 1
ResourceSampleCount = 5
ResourceSampleInterval = 250 * time.Millisecond
ResourceExpectedCountDrift = 0
DashboardRSSDeltaBytes uint64 = 64 * 1024 * 1024
AgentRSSDeltaBytes uint64 = 32 * 1024 * 1024
TransferHeapBytes uint64 = 16 * 1024 * 1024
)
type ResourceBudgetInput struct {
WarmupRuns int
SampleCount int
SampleInterval time.Duration
ChildProcessCountDrift int
ListenerCountDrift int
NonStdioFDCountDrift int
DashboardRSSDeltaBytes uint64
AgentRSSDeltaBytes uint64
TransferHeapBytes uint64
}
type ResourceBudget struct{ input ResourceBudgetInput }
func NewResourceBudget(input ResourceBudgetInput) (ResourceBudget, error) {
if input.WarmupRuns < 1 || input.SampleCount < 1 || input.SampleInterval <= 0 || input.ChildProcessCountDrift < 0 || input.ListenerCountDrift < 0 || input.NonStdioFDCountDrift < 0 || input.DashboardRSSDeltaBytes == 0 || input.AgentRSSDeltaBytes == 0 || input.TransferHeapBytes == 0 {
return ResourceBudget{}, errors.New("invalid resource budget")
}
return ResourceBudget{input: input}, nil
}
func (b ResourceBudget) WarmupRuns() int { return b.input.WarmupRuns }
func (b ResourceBudget) SampleCount() int { return b.input.SampleCount }
func (b ResourceBudget) SampleInterval() time.Duration { return b.input.SampleInterval }
func (b ResourceBudget) ChildProcessCountDrift() int { return b.input.ChildProcessCountDrift }
func (b ResourceBudget) ListenerCountDrift() int { return b.input.ListenerCountDrift }
func (b ResourceBudget) NonStdioFDCountDrift() int { return b.input.NonStdioFDCountDrift }
func (b ResourceBudget) DashboardRSSDeltaBytes() uint64 { return b.input.DashboardRSSDeltaBytes }
func (b ResourceBudget) AgentRSSDeltaBytes() uint64 { return b.input.AgentRSSDeltaBytes }
func (b ResourceBudget) TransferHeapBytes() uint64 { return b.input.TransferHeapBytes }
func DefaultResourceBudget() ResourceBudget {
return ResourceBudget{input: ResourceBudgetInput{WarmupRuns: ResourceWarmupRuns, SampleCount: ResourceSampleCount, SampleInterval: ResourceSampleInterval, ChildProcessCountDrift: ResourceExpectedCountDrift, ListenerCountDrift: ResourceExpectedCountDrift, NonStdioFDCountDrift: ResourceExpectedCountDrift, DashboardRSSDeltaBytes: DashboardRSSDeltaBytes, AgentRSSDeltaBytes: AgentRSSDeltaBytes, TransferHeapBytes: TransferHeapBytes}}
}
@@ -0,0 +1,105 @@
package contract
import (
"testing"
"time"
)
func TestContract_Profiles(t *testing.T) {
pr, err := ProfileByName("pr-full")
if err != nil {
t.Fatalf("parse pr-full: %v", err)
}
if pr.JobTimeout() != 75*time.Minute || pr.SuiteDeadline() != 55*time.Minute {
t.Fatalf("unexpected pr-full deadlines: %#v", pr)
}
if pr.Seed() != Seed(0x4e5a4841) || pr.AgentCount() != 8 || pr.StressRounds() != 4 || pr.ConcurrentOperations() != 64 {
t.Fatalf("unexpected pr-full load: %#v", pr)
}
if pr.ConcurrentSessions() != 4 || pr.TransferPairs() != 1 || pr.DashboardRestartCycles() != 1 {
t.Fatalf("unexpected pr-full sessions: %#v", pr)
}
soak, err := ProfileByName("soak")
if err != nil {
t.Fatalf("parse soak: %v", err)
}
if soak.SuiteDeadline() != 150*time.Minute || soak.AgentCount() != 20 || soak.Iterations() != 3 || soak.DashboardRestartCycles() != 10 || soak.TransferPairs() != 5 || !soak.StreamBoundaryCheck() {
t.Fatalf("unexpected soak profile: %#v", soak)
}
if soak.JobTimeout() != 150*time.Minute || soak.Seed() != DefaultSeed || soak.StreamBoundaryAllowed() != 40 || soak.StreamBoundaryRejected() != 41 || soak.TransferBytes() != 100*1024*1024 {
t.Fatalf("unexpected soak limits: %#v", soak)
}
if _, err := ProfileByName("unknown"); err == nil {
t.Fatal("unknown profile accepted")
}
}
func TestContract_ResourceBudget(t *testing.T) {
budget, err := NewResourceBudget(ResourceBudgetInput{
WarmupRuns: 1,
SampleCount: 5,
SampleInterval: 250 * time.Millisecond,
ChildProcessCountDrift: 0,
ListenerCountDrift: 0,
NonStdioFDCountDrift: 0,
DashboardRSSDeltaBytes: 64 * 1024 * 1024,
AgentRSSDeltaBytes: 32 * 1024 * 1024,
TransferHeapBytes: 16 * 1024 * 1024,
})
if err != nil {
t.Fatalf("construct budget: %v", err)
}
if budget.WarmupRuns() != 1 || budget.SampleCount() != 5 || budget.SampleInterval() != 250*time.Millisecond || budget.ChildProcessCountDrift() != 0 || budget.ListenerCountDrift() != 0 || budget.NonStdioFDCountDrift() != 0 {
t.Fatalf("unexpected sampling budget: %#v", budget)
}
if budget.DashboardRSSDeltaBytes() != 64*1024*1024 || budget.AgentRSSDeltaBytes() != 32*1024*1024 || budget.TransferHeapBytes() != 16*1024*1024 {
t.Fatalf("unexpected memory budget: %#v", budget)
}
}
func TestContract_ResourceBudgetRejectsInvalidInput(t *testing.T) {
base := ResourceBudgetInput{WarmupRuns: 1, SampleCount: 5, SampleInterval: 250 * time.Millisecond, DashboardRSSDeltaBytes: 1, AgentRSSDeltaBytes: 1, TransferHeapBytes: 1}
for name, mutate := range map[string]func(*ResourceBudgetInput){
"zero warmup": func(input *ResourceBudgetInput) { input.WarmupRuns = 0 },
"zero samples": func(input *ResourceBudgetInput) { input.SampleCount = 0 },
"negative samples": func(input *ResourceBudgetInput) { input.SampleCount = -1 },
"zero interval": func(input *ResourceBudgetInput) { input.SampleInterval = 0 },
"negative interval": func(input *ResourceBudgetInput) { input.SampleInterval = -time.Millisecond },
"negative child drift": func(input *ResourceBudgetInput) { input.ChildProcessCountDrift = -1 },
"missing dashboard threshold": func(input *ResourceBudgetInput) { input.DashboardRSSDeltaBytes = 0 },
"missing agent threshold": func(input *ResourceBudgetInput) { input.AgentRSSDeltaBytes = 0 },
"missing heap threshold": func(input *ResourceBudgetInput) { input.TransferHeapBytes = 0 },
} {
t.Run(name, func(t *testing.T) {
input := base
mutate(&input)
if _, err := NewResourceBudget(input); err == nil {
t.Fatal("invalid budget accepted")
}
})
}
}
func TestContract_CLIValues(t *testing.T) {
paths, err := NewPaths("/src/nezha", "/src/agent", "/tmp/results")
if err != nil {
t.Fatalf("construct paths: %v", err)
}
if paths.NezhaSource().String() != "/src/nezha" || paths.AgentSource().String() != "/src/agent" || paths.ResultsDir().String() != "/tmp/results" {
t.Fatalf("unexpected paths: %#v", paths)
}
scenario, err := NewScenario("metadata")
if err != nil || scenario.String() != "metadata" {
t.Fatalf("construct scenario: %q %v", scenario.String(), err)
}
fault, err := NewFault("transfer-hash")
if err != nil || fault.String() != "transfer-hash" {
t.Fatalf("construct fault: %q %v", fault.String(), err)
}
for _, invalid := range []string{"", "../escape", "has space"} {
if _, err := NewScenario(invalid); err == nil {
t.Fatalf("invalid scenario accepted: %q", invalid)
}
}
}
@@ -0,0 +1,96 @@
package contract
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
type ProfileName string
type Seed uint64
const (
ProfilePRFull ProfileName = "pr-full"
ProfileSoak ProfileName = "soak"
DefaultSeed Seed = 0x4e5a4841
PRFullJobTimeout = 75 * time.Minute
PRFullSuiteDeadline = 55 * time.Minute
PRFullAgentCount = 8
PRFullStressRounds = 4
PRFullConcurrentOperations = 64
PRFullConcurrentSessions = 4
PRFullTransferPairs = 1
PRFullRestartCycles = 1
SoakJobTimeout = 150 * time.Minute
SoakSuiteDeadline = 150 * time.Minute
SoakAgentCount = 20
SoakStressRounds = 4
SoakConcurrentOperations = 160
SoakConcurrentSessions = 4
SoakTransferPairs = 5
SoakRestartCycles = 10
SoakIterations = 3
TransferBytes uint64 = 100 * 1024 * 1024
StreamBoundaryAllowed = 40
StreamBoundaryRejected = 41
)
type Profile struct {
name ProfileName
jobTimeout time.Duration
suiteDeadline time.Duration
seed Seed
agentCount int
stressRounds int
concurrentOperations int
concurrentSessions int
transferPairs int
dashboardRestartCycles int
iterations int
streamBoundaryAllowed int
streamBoundaryRejected int
}
func ProfileByName(name string) (Profile, error) {
switch ProfileName(name) {
case ProfilePRFull:
return Profile{name: ProfilePRFull, jobTimeout: PRFullJobTimeout, suiteDeadline: PRFullSuiteDeadline, seed: DefaultSeed, agentCount: PRFullAgentCount, stressRounds: PRFullStressRounds, concurrentOperations: PRFullConcurrentOperations, concurrentSessions: PRFullConcurrentSessions, transferPairs: PRFullTransferPairs, dashboardRestartCycles: PRFullRestartCycles, iterations: 1, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil
case ProfileSoak:
return Profile{name: ProfileSoak, jobTimeout: SoakJobTimeout, suiteDeadline: SoakSuiteDeadline, seed: DefaultSeed, agentCount: SoakAgentCount, stressRounds: SoakStressRounds, concurrentOperations: SoakConcurrentOperations, concurrentSessions: SoakConcurrentSessions, transferPairs: SoakTransferPairs, dashboardRestartCycles: SoakRestartCycles, iterations: SoakIterations, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil
default:
return Profile{}, errors.New("unknown profile; expected pr-full or soak")
}
}
func (p Profile) Name() ProfileName { return p.name }
func (p Profile) JobTimeout() time.Duration { return p.jobTimeout }
func (p Profile) SuiteDeadline() time.Duration { return p.suiteDeadline }
func (p Profile) Seed() Seed { return p.seed }
func (p Profile) AgentCount() int { return p.agentCount }
func (p Profile) StressRounds() int { return p.stressRounds }
func (p Profile) ConcurrentOperations() int { return p.concurrentOperations }
func (p Profile) ConcurrentSessions() int { return p.concurrentSessions }
func (p Profile) TransferPairs() int { return p.transferPairs }
func (p Profile) DashboardRestartCycles() int { return p.dashboardRestartCycles }
func (p Profile) Iterations() int { return p.iterations }
func (p Profile) TransferBytes() uint64 { return TransferBytes }
func (p Profile) StreamBoundaryAllowed() int { return p.streamBoundaryAllowed }
func (p Profile) StreamBoundaryRejected() int { return p.streamBoundaryRejected }
func (p Profile) StreamBoundaryCheck() bool {
return p.streamBoundaryAllowed > 0 && p.streamBoundaryRejected > p.streamBoundaryAllowed
}
var ErrInvalidSeed = errors.New("invalid seed")
func ParseSeed(raw string) (Seed, error) {
value, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X"), 16, 64)
if err != nil || value == 0 {
return 0, fmt.Errorf("%w; expected nonzero hexadecimal", ErrInvalidSeed)
}
return Seed(value), nil
}
@@ -0,0 +1,175 @@
package contract
import (
"errors"
"slices"
)
const (
ScenarioMetadata = "metadata"
ScenarioRegistrationConfigExec = "registration-config-exec"
ScenarioNAT = "nat"
ScenarioLegacyFM = "legacy-fm"
ScenarioTerminal = "terminal"
ScenarioMCPFilesystem = "mcp-filesystem"
ScenarioTransfer100MiB = "transfer-100mib"
ScenarioReconnect = "reconnect"
FaultAgentBadSecret = "agent-bad-secret"
FaultTransferHash = "transfer-hash"
FaultDashboardExit = "dashboard-exit"
)
type DedicatedArtifactKind uint8
const (
DedicatedArtifactNone DedicatedArtifactKind = iota
DedicatedArtifactTransfer
DedicatedArtifactReconnect
)
type ScenarioExecutionKind uint8
const (
ScenarioExecutionMetadata ScenarioExecutionKind = iota
ScenarioExecutionRegistrationConfigExec
ScenarioExecutionNAT
ScenarioExecutionLegacyFM
ScenarioExecutionTerminal
ScenarioExecutionMCPFilesystem
ScenarioExecutionTransfer
ScenarioExecutionReconnect
)
type ScenarioDefinition struct {
Name string
AllowedFaults []string
Execution ScenarioExecutionKind
DedicatedArtifact DedicatedArtifactKind
}
type AssertionDefinition struct {
Name string
Passed bool
}
const (
AssertionInjectedFault = "injected fault produced the expected scenario failure"
AssertionTransferWarmup = "small real upload and download warm-up precedes event and deadline quiescence"
AssertionTransferUpload = "exact 100MiB upload has size mode SHA and create_dirs"
AssertionTransferDownload = "exact 100MiB download has equal nonempty SHA"
AssertionTransferUploadReplay = "upload token replay is typed unauthorized"
AssertionTransferDownloadReplay = "download token replay is typed unauthorized"
AssertionTransferOversize = "100MiB plus one upload is typed too large"
AssertionTransferResidue = "Dashboard spool and Agent temp residue are zero"
AssertionTransferSentinels = "outside-root sentinels remain unchanged"
AssertionTransferHeap = "retained live heap stays within 16MiB"
AssertionTransferCleanup = "process listener and workspace cleanup completed"
AssertionTransferHashRejected = "transfer-hash rejects upload with typed 502"
AssertionTransferHashTargetAbsent = "transfer-hash leaves target absent"
AssertionReconnectDisconnect = "Dashboard disconnect barrier stopped generation one"
AssertionReconnectDashboard = "Dashboard generation two preserves fixture and recreates runtime clients"
AssertionReconnectIdentity = "Agent reconnect preserves exact server ID and UUID"
AssertionReconnectReceipts = "post-reconnect MCP task and result receipts are exactly once"
AssertionReconnectStale = "stale Dashboard generation cannot receive new task receipts"
AssertionReconnectAgent = "Agent restart advances state stream and preserves config identity"
AssertionReconnectSentinel = "outside-root sentinel remains unchanged"
AssertionReconnectCleanup = "multi-generation process listener and workspace cleanup completed"
)
func (definition ScenarioDefinition) DedicatedArtifactName() string {
switch definition.DedicatedArtifact {
case DedicatedArtifactNone:
return ""
case DedicatedArtifactTransfer:
return "transfer.json"
case DedicatedArtifactReconnect:
return "reconnect.json"
default:
return ""
}
}
func (definition ScenarioDefinition) Assertions(fault string) []AssertionDefinition {
switch definition.Name {
case ScenarioTransfer100MiB:
if fault == FaultTransferHash {
return assertions(
AssertionTransferWarmup, AssertionTransferHashRejected, AssertionTransferHashTargetAbsent,
AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferCleanup, AssertionInjectedFault,
)
}
return assertions(
AssertionTransferWarmup, AssertionTransferUpload, AssertionTransferDownload,
AssertionTransferUploadReplay, AssertionTransferDownloadReplay, AssertionTransferOversize,
AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferHeap, AssertionTransferCleanup,
)
case ScenarioReconnect:
if fault == FaultDashboardExit {
return assertions(AssertionReconnectDisconnect, AssertionReconnectSentinel, AssertionReconnectCleanup, AssertionInjectedFault)
}
return assertions(
AssertionReconnectDisconnect, AssertionReconnectDashboard, AssertionReconnectIdentity,
AssertionReconnectReceipts, AssertionReconnectStale, AssertionReconnectAgent,
AssertionReconnectSentinel, AssertionReconnectCleanup,
)
default:
return nil
}
}
func assertions(names ...string) []AssertionDefinition {
definitions := make([]AssertionDefinition, 0, len(names))
for _, name := range names {
definitions = append(definitions, AssertionDefinition{Name: name, Passed: name != AssertionInjectedFault})
}
return definitions
}
var scenarioDefinitions = []ScenarioDefinition{
{Name: ScenarioMetadata, AllowedFaults: []string{""}, Execution: ScenarioExecutionMetadata},
{Name: ScenarioRegistrationConfigExec, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionRegistrationConfigExec},
{Name: ScenarioNAT, AllowedFaults: []string{""}, Execution: ScenarioExecutionNAT},
{Name: ScenarioLegacyFM, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionLegacyFM},
{Name: ScenarioTerminal, AllowedFaults: []string{""}, Execution: ScenarioExecutionTerminal},
{Name: ScenarioMCPFilesystem, AllowedFaults: []string{""}, Execution: ScenarioExecutionMCPFilesystem},
{Name: ScenarioTransfer100MiB, AllowedFaults: []string{"", FaultTransferHash}, Execution: ScenarioExecutionTransfer, DedicatedArtifact: DedicatedArtifactTransfer},
{Name: ScenarioReconnect, AllowedFaults: []string{"", FaultDashboardExit}, Execution: ScenarioExecutionReconnect, DedicatedArtifact: DedicatedArtifactReconnect},
}
func ScenarioDefinitions() []ScenarioDefinition {
definitions := make([]ScenarioDefinition, len(scenarioDefinitions))
copy(definitions, scenarioDefinitions)
for index := range definitions {
definitions[index].AllowedFaults = slices.Clone(definitions[index].AllowedFaults)
}
return definitions
}
func ScenarioDefinitionByName(name string) (ScenarioDefinition, error) {
for _, definition := range scenarioDefinitions {
if definition.Name == name {
definition.AllowedFaults = slices.Clone(definition.AllowedFaults)
return definition, nil
}
}
return ScenarioDefinition{}, errors.New("unsupported scenario")
}
func ValidateScenarioFault(scenario Scenario, fault Fault) error {
definition, err := ScenarioDefinitionByName(scenario.String())
if err != nil {
return err
}
if !slices.Contains(definition.AllowedFaults, fault.String()) {
return errors.New("unsupported fault for scenario")
}
return nil
}
func IsSupportedScenario(name string) bool {
_, err := ScenarioDefinitionByName(name)
return err == nil
}
@@ -0,0 +1,83 @@
package contract
import (
"slices"
"testing"
)
func TestContract_ScenarioFaultRegistry(t *testing.T) {
tests := []struct {
scenario string
fault string
valid bool
}{
{ScenarioTransfer100MiB, "", true},
{ScenarioTransfer100MiB, FaultTransferHash, true},
{ScenarioTransfer100MiB, FaultDashboardExit, false},
{ScenarioReconnect, "", true},
{ScenarioReconnect, FaultDashboardExit, true},
{ScenarioReconnect, FaultTransferHash, false},
{ScenarioRegistrationConfigExec, FaultAgentBadSecret, true},
{ScenarioLegacyFM, FaultAgentBadSecret, true},
{ScenarioMCPFilesystem, FaultAgentBadSecret, false},
{"future-scenario", "", false},
}
for _, test := range tests {
scenario, err := NewScenario(test.scenario)
if err != nil {
t.Fatalf("construct scenario %q: %v", test.scenario, err)
}
fault := Fault{}
if test.fault != "" {
fault, err = NewFault(test.fault)
if err != nil {
t.Fatalf("construct fault %q: %v", test.fault, err)
}
}
err = ValidateScenarioFault(scenario, fault)
if (err == nil) != test.valid {
t.Fatalf("scenario=%q fault=%q valid=%t err=%v", test.scenario, test.fault, test.valid, err)
}
}
}
func TestContract_SyntacticConstructorsPreserveArbitraryValidNames(t *testing.T) {
if _, err := NewScenario("future-scenario"); err != nil {
t.Fatalf("valid future scenario syntax rejected: %v", err)
}
if _, err := NewFault("future-fault"); err != nil {
t.Fatalf("valid future fault syntax rejected: %v", err)
}
}
func TestContract_ScenarioDefinitionsAreDeterministicAndTyped(t *testing.T) {
definitions := ScenarioDefinitions()
wantNames := []string{
ScenarioMetadata,
ScenarioRegistrationConfigExec,
ScenarioNAT,
ScenarioLegacyFM,
ScenarioTerminal,
ScenarioMCPFilesystem,
ScenarioTransfer100MiB,
ScenarioReconnect,
}
gotNames := make([]string, 0, len(definitions))
seenExecution := make(map[ScenarioExecutionKind]struct{}, len(definitions))
for _, definition := range definitions {
gotNames = append(gotNames, definition.Name)
if len(definition.AllowedFaults) == 0 || definition.AllowedFaults[0] != "" {
t.Fatalf("scenario %q does not explicitly allow the no-fault path", definition.Name)
}
if _, exists := seenExecution[definition.Execution]; exists {
t.Fatalf("execution kind is duplicated: %d", definition.Execution)
}
seenExecution[definition.Execution] = struct{}{}
if definition.DedicatedArtifactName() == "" && definition.DedicatedArtifact != DedicatedArtifactNone {
t.Fatalf("scenario %q has unnamed dedicated artifact", definition.Name)
}
}
if !slices.Equal(gotNames, wantNames) {
t.Fatalf("scenario enumeration order=%v want=%v", gotNames, wantNames)
}
}
@@ -0,0 +1,73 @@
package contract
import (
"errors"
"path/filepath"
"regexp"
"strings"
)
var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
type NezhaSourcePath struct{ value string }
type AgentSourcePath struct{ value string }
type ResultsPath struct{ value string }
type Paths struct {
nezhaSource NezhaSourcePath
agentSource AgentSourcePath
resultsDir ResultsPath
}
func NewPaths(nezhaSource, agentSource, resultsDir string) (Paths, error) {
nezha, err := cleanAbsolutePath(nezhaSource)
if err != nil {
return Paths{}, errors.New("invalid --nezha-source path")
}
agent, err := cleanAbsolutePath(agentSource)
if err != nil {
return Paths{}, errors.New("invalid --agent-source path")
}
results, err := cleanAbsolutePath(resultsDir)
if err != nil {
return Paths{}, errors.New("invalid --results-dir path")
}
return Paths{nezhaSource: NezhaSourcePath{value: nezha}, agentSource: AgentSourcePath{value: agent}, resultsDir: ResultsPath{value: results}}, nil
}
func cleanAbsolutePath(raw string) (string, error) {
if strings.TrimSpace(raw) == "" || !filepath.IsAbs(raw) {
return "", errors.New("path must be absolute")
}
return filepath.Clean(raw), nil
}
func (p Paths) NezhaSource() NezhaSourcePath { return p.nezhaSource }
func (p Paths) AgentSource() AgentSourcePath { return p.agentSource }
func (p Paths) ResultsDir() ResultsPath { return p.resultsDir }
func (p NezhaSourcePath) String() string { return p.value }
func (p AgentSourcePath) String() string { return p.value }
func (p ResultsPath) String() string { return p.value }
type Scenario struct{ value string }
func NewScenario(raw string) (Scenario, error) {
if !namePattern.MatchString(raw) {
return Scenario{}, errors.New("invalid scenario name")
}
return Scenario{value: raw}, nil
}
func (s Scenario) String() string { return s.value }
type Fault struct{ value string }
func NewFault(raw string) (Fault, error) {
if !namePattern.MatchString(raw) {
return Fault{}, errors.New("invalid fault name")
}
return Fault{value: raw}, nil
}
func (f Fault) String() string { return f.value }
func (f Fault) IsZero() bool { return f.value == "" }