Protect initial pipeline stage resume semantics

This commit is contained in:
2026-08-30 12:54:38 +00:00
parent 82cb53e107
commit 4d6086fefb
13 changed files with 549 additions and 5 deletions

View File

@@ -130,6 +130,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
}

View File

@@ -64,6 +64,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}

View File

@@ -22,6 +22,7 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}
@@ -60,6 +61,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe", "merge")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}
@@ -208,6 +210,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
}

View File

@@ -642,6 +642,7 @@ func TestExecuteStagesForceRerunsSucceeded(t *testing.T) {
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
@@ -983,6 +984,7 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
manifestPath := manifestPathFor(cfg)
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
@@ -1232,6 +1234,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
ProducerRunID: "20260501T000000Z-deadbeef",
},
})
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}

View File

@@ -2,8 +2,10 @@ package app
import (
"context"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
@@ -13,3 +15,37 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
return executePlan(ctx, cfg, plan, opts)
}
func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.Manifest, names ...string) {
t.Helper()
providers := make(map[string]stage.SemanticConfigFingerprinter)
for _, candidate := range stage.All() {
if provider, ok := candidate.(stage.SemanticConfigFingerprinter); ok {
providers[candidate.Name()] = provider
}
}
for _, name := range names {
record := m.Stages[name]
if record == nil {
t.Fatalf("stage %q must exist before semantic evidence is seeded", name)
}
provider, ok := providers[name]
if !ok {
t.Fatalf("stage %q has no semantic fingerprint provider", name)
}
fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg})
if err != nil {
t.Fatalf("fingerprint stage %q: %v", name, err)
}
record.SemanticConfig = &fingerprint
}
}
func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config {
t.Helper()
cfg, err := config.Load(pipelinePath, campaignPath, sessionPath)
if err != nil {
t.Fatalf("load config for semantic evidence: %v", err)
}
return cfg
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
@@ -39,6 +40,21 @@ type semanticResumeCheckingStage struct {
validationCalls *int
}
type semanticContractRunStub struct {
name string
provider stage.SemanticConfigFingerprinter
runs *int
}
func (s semanticContractRunStub) Name() string { return s.name }
func (s semanticContractRunStub) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
*s.runs++
return &stage.StageResult{}, nil
}
func (s semanticContractRunStub) SemanticConfigFingerprint(env *stage.Env) (manifest.SemanticConfigFingerprint, error) {
return s.provider.SemanticConfigFingerprint(env)
}
func (s semanticResumeCheckingStage) ValidateResume(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (stage.ResumeValidation, error) {
if s.validationCalls != nil {
*s.validationCalls++
@@ -295,6 +311,78 @@ func TestSemanticMismatchInvalidatesOnlyFixedDependents(t *testing.T) {
}
}
func TestInitialPipelineSemanticChangesRerunOnlyAffectedLineage(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*config.Config)
wantRuns [3]int
}{
{name: "prepare selection", mutate: func(cfg *config.Config) {
cfg.Session.PreviousSessionID = "2026-04-26"
}, wantRuns: [3]int{1, 1, 1}},
{name: "transcribe language", mutate: func(cfg *config.Config) {
cfg.Pipeline.WhisperX.Language = "fr"
}, wantRuns: [3]int{0, 1, 1}},
{name: "merge transformation", mutate: func(cfg *config.Config) {
value := 1.75
cfg.Pipeline.Seriatim.CoalesceGap = &value
}, wantRuns: [3]int{0, 0, 1}},
} {
t.Run(test.name, func(t *testing.T) {
cfg := testConfig(t)
names := []string{"prepare", "transcribe", "merge"}
providers := make([]stage.SemanticConfigFingerprinter, len(names))
for index, name := range names {
providers[index] = canonicalSemanticProvider(t, name)
}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
for _, candidate := range stage.All() {
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
}
for index, name := range names {
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
seed.Stages[name].SemanticConfig = &fingerprint
}
store := &manifest.LocalStore{}
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
test.mutate(cfg)
runs := [3]int{}
selected := make([]stage.Stage, 0, len(names))
for index, name := range names {
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if runs != test.wantRuns {
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
}
})
}
}
func canonicalSemanticProvider(t *testing.T, name string) stage.SemanticConfigFingerprinter {
t.Helper()
for _, candidate := range stage.All() {
if candidate.Name() != name {
continue
}
provider, ok := candidate.(stage.SemanticConfigFingerprinter)
if !ok {
t.Fatalf("canonical stage %q does not implement semantic fingerprinting", name)
}
return provider
}
t.Fatalf("canonical stage %q not found", name)
return nil
}
func semanticFingerprint(version int, seed string) manifest.SemanticConfigFingerprint {
digest := sha256.Sum256([]byte(seed))
return manifest.SemanticConfigFingerprint{Version: version, Digest: hex.EncodeToString(digest[:])}

View File

@@ -0,0 +1,180 @@
package stage
import (
"fmt"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
const (
prepareSemanticConfigVersion = 1
transcribeSemanticConfigVersion = 1
mergeSemanticConfigVersion = 1
)
type prepareSemanticConfig struct {
CampaignID string `json:"campaign_id"`
SessionID string `json:"session_id"`
PreviousSession string `json:"previous_session_id"`
Audio prepareAudioSelection `json:"audio"`
StableInputs []prepareStableInputSelection `json:"stable_inputs"`
PreviousArtifacts []preparePreviousRequirement `json:"previous_artifacts"`
}
type prepareAudioSelection struct {
Mode string `json:"mode"`
LocalNames []string `json:"local_names,omitempty"`
S3Bucket string `json:"s3_bucket,omitempty"`
S3RootPrefix string `json:"s3_root_prefix,omitempty"`
S3AudioPrefix string `json:"s3_audio_prefix,omitempty"`
}
type prepareStableInputSelection struct {
Kind string `json:"kind"`
Source string `json:"source"`
Present bool `json:"present"`
}
type preparePreviousRequirement struct {
Name string `json:"name"`
Required bool `json:"required"`
OutputPath string `json:"output_path,omitempty"`
}
type transcribeSemanticConfig struct {
ServiceURL string `json:"service_url"`
Language string `json:"language"`
}
type mergeSemanticConfig struct {
Operation string `json:"operation"`
OutputSchema string `json:"output_schema"`
CoalesceGap *float64 `json:"coalesce_gap,omitempty"`
OverlapWordRunGap *float64 `json:"overlap_word_run_gap,omitempty"`
OverlapWordRunReorderWindow *float64 `json:"overlap_word_run_reorder_window,omitempty"`
BackchannelMaxDuration *float64 `json:"backchannel_max_duration,omitempty"`
FillerMaxDuration *float64 `json:"filler_max_duration,omitempty"`
}
func (prepareStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
payload, err := buildPrepareSemanticConfig(env)
if err != nil {
return manifest.SemanticConfigFingerprint{}, err
}
return FingerprintSemanticConfig(prepareSemanticConfigVersion, payload)
}
func (transcribeStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("transcribe semantic configuration requires resolved pipeline config")
}
whisper := env.Config.Pipeline.WhisperX
return FingerprintSemanticConfig(transcribeSemanticConfigVersion, transcribeSemanticConfig{
ServiceURL: strings.TrimSpace(whisper.TranscribeURL),
Language: strings.TrimSpace(whisper.Language),
})
}
func (mergeStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("merge semantic configuration requires resolved pipeline config")
}
seriatim := env.Config.Pipeline.Seriatim
return FingerprintSemanticConfig(mergeSemanticConfigVersion, mergeSemanticConfig{
Operation: "merge",
OutputSchema: strings.TrimSpace(seriatim.OutputSchema),
CoalesceGap: cloneFloat64(seriatim.CoalesceGap),
OverlapWordRunGap: cloneFloat64(seriatim.Env.OverlapWordRunGap),
OverlapWordRunReorderWindow: cloneFloat64(seriatim.Env.OverlapWordRunReorderWindow),
BackchannelMaxDuration: cloneFloat64(seriatim.Env.BackchannelMaxDuration),
FillerMaxDuration: cloneFloat64(seriatim.Env.FillerMaxDuration),
})
}
func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires resolved pipeline and session config")
}
cfg := env.Config
requirements, err := collectPreparePreviousRequirements(cfg, env.EffectiveArtifacts)
if err != nil {
return prepareSemanticConfig{}, fmt.Errorf("resolve previous artifact requirements: %w", err)
}
payload := prepareSemanticConfig{
CampaignID: strings.TrimSpace(cfg.Session.Campaign),
SessionID: strings.TrimSpace(cfg.Session.SessionID),
PreviousSession: strings.TrimSpace(cfg.Session.PreviousSessionID),
Audio: prepareAudioSemantics(cfg),
StableInputs: prepareStableInputSemantics(cfg),
}
for _, requirement := range requirements {
entry := preparePreviousRequirement{Name: strings.TrimSpace(requirement.Name), Required: requirement.Required}
if cfg.Pipeline.Scriptorium != nil {
entry.OutputPath = strings.TrimSpace(cfg.Pipeline.Scriptorium.Artifacts[requirement.Name].OutputPath)
}
payload.PreviousArtifacts = append(payload.PreviousArtifacts, entry)
}
return payload, nil
}
func prepareAudioSemantics(cfg *config.Config) prepareAudioSelection {
inputs := cfg.Session.Inputs
if inputs.AudioS3 != nil {
selection := prepareAudioSelection{Mode: "s3", S3AudioPrefix: strings.TrimSpace(inputs.AudioS3.Prefix)}
if cfg.Pipeline.Storage.S3 != nil {
selection.S3Bucket = strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket)
selection.S3RootPrefix = strings.TrimSpace(cfg.Pipeline.Storage.S3.RootPrefix)
}
return selection
}
selection := prepareAudioSelection{Mode: "local_directory"}
if len(inputs.AudioFiles) > 0 {
selection.Mode = "local_files"
destinations := localAudioDestinations(inputs.AudioFiles)
for _, name := range destinations {
selection.LocalNames = append(selection.LocalNames, filepath.ToSlash(name))
}
sort.Strings(selection.LocalNames)
}
return selection
}
func prepareStableInputSemantics(cfg *config.Config) []prepareStableInputSelection {
stable := cfg.StableInputs
return []prepareStableInputSelection{
prepareStableInput("speakers", stable.SpeakersFile, cfg.Session.Inputs.SpeakersFile),
prepareStableInput("autocorrect", stable.AutocorrectFile, cfg.Session.Inputs.AutocorrectFile),
prepareStableInput("glossary", stable.GlossaryFile, cfg.Session.Inputs.GlossaryFile),
prepareStableInput("players", stable.PlayersFile, cfg.Session.Inputs.PlayersFile),
prepareStableInput("party", stable.PartyFile, cfg.Session.Inputs.PartyFile),
prepareStableInput("spell_catalog", stable.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile),
}
}
func prepareStableInput(kind string, resolved config.ResolvedInputFile, fallback string) prepareStableInputSelection {
source := strings.TrimSpace(resolved.Source)
if source == "" {
source = "session_config"
}
path := resolved.Path
if strings.TrimSpace(path) == "" {
path = fallback
}
return prepareStableInputSelection{Kind: kind, Source: source, Present: strings.TrimSpace(path) != ""}
}
func cloneFloat64(value *float64) *float64 {
if value == nil {
return nil
}
copy := *value
return &copy
}
var _ SemanticConfigFingerprinter = prepareStage{}
var _ SemanticConfigFingerprinter = transcribeStage{}
var _ SemanticConfigFingerprinter = mergeStage{}

View File

@@ -0,0 +1,174 @@
package stage
import (
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestPrepareSemanticConfigSensitivity(t *testing.T) {
assertSemanticFingerprintChanges(t, prepareStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "audio mode", mutate: func(env *Env) {
env.Config.Session.Inputs.AudioFiles = nil
env.Config.Session.Inputs.AudioS3 = &config.SessionAudioS3Input{Prefix: "sessions/audio"}
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"}
}},
{name: "audio canonical name", mutate: func(env *Env) {
env.Config.Session.Inputs.AudioFiles = []string{"/audio/other.flac"}
}},
{name: "previous session", mutate: func(env *Env) {
env.Config.Session.PreviousSessionID = "2026-04-26"
}},
{name: "stable input owner", mutate: func(env *Env) {
env.Config.StableInputs.PartyFile.Source = "campaign_config"
}},
})
assertSemanticFingerprintUnchanged(t, prepareStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "workspace and spool", mutate: func(env *Env) {
env.Config.Pipeline.Workspace.Root = "/different/work"
env.Config.Pipeline.Spool.Root = "/different/spool"
env.Config.Pipeline.Cache.Root = "/different/cache"
}},
{name: "absolute stable source path", mutate: func(env *Env) {
env.Config.StableInputs.PartyFile.Path = "/relocated/party.yml"
env.Config.StableInputs.PartyFile.ConfigPath = "/relocated/campaign.yml"
}},
{name: "absolute unique audio source", mutate: func(env *Env) {
env.Config.Session.Inputs.AudioFiles = []string{"/relocated/speaker.flac"}
}},
})
}
func TestTranscribeSemanticConfigSensitivity(t *testing.T) {
assertSemanticFingerprintChanges(t, transcribeStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "language", mutate: func(env *Env) { env.Config.Pipeline.WhisperX.Language = "fr" }},
{name: "service", mutate: func(env *Env) { env.Config.Pipeline.WhisperX.TranscribeURL = "https://other.example/transcribe" }},
})
assertSemanticFingerprintUnchanged(t, transcribeStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "transport tuning", mutate: func(env *Env) {
env.Config.Pipeline.WhisperX.Timeout = "30m"
env.Config.Pipeline.WhisperX.Retries = intPointer(9)
env.Config.Pipeline.WhisperX.Concurrency = intPointer(12)
env.Config.Pipeline.WhisperX.RetryDelay = "10s"
}},
})
}
func TestMergeSemanticConfigSensitivity(t *testing.T) {
assertSemanticFingerprintChanges(t, mergeStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "output schema", mutate: func(env *Env) { env.Config.Pipeline.Seriatim.OutputSchema = "seriatim.transcript.v2" }},
{name: "coalesce gap", mutate: func(env *Env) { env.Config.Pipeline.Seriatim.CoalesceGap = floatPointer(1.25) }},
{name: "overlap transformation", mutate: func(env *Env) { env.Config.Pipeline.Seriatim.Env.OverlapWordRunGap = floatPointer(0.75) }},
})
assertSemanticFingerprintUnchanged(t, mergeStage{}, initialSemanticEnv(), []struct {
name string
mutate func(*Env)
}{
{name: "process and diagnostics", mutate: func(env *Env) {
env.Config.Pipeline.Seriatim.Binary = "/opt/seriatim"
env.Config.Pipeline.Seriatim.Timeout = "45m"
env.Config.Pipeline.Seriatim.Report = boolPointer(false)
}},
})
}
func initialSemanticEnv() *Env {
return &Env{Config: &config.Config{
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: "/work"},
Spool: config.SpoolConfig{Root: "/spool"},
Cache: config.CacheConfig{Root: "/cache"},
WhisperX: config.WhisperXConfig{
TranscribeURL: "https://whisper.example/transcribe", Language: "en",
Timeout: "10m", Retries: intPointer(3), RetryDelay: "1s", Concurrency: intPointer(2),
},
Seriatim: config.SeriatimConfig{
Binary: "seriatim", Timeout: "10m", OutputSchema: "seriatim.transcript.v1",
CoalesceGap: floatPointer(0.5), Report: boolPointer(true),
Env: config.SeriatimEnvConfig{OverlapWordRunGap: floatPointer(0.25)},
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03", Campaign: "campaign",
Inputs: config.SessionInputsConfig{
AudioFiles: []string{"/audio/speaker.flac"}, PartyFile: "party.yml",
},
},
StableInputs: config.ResolvedStableInputs{
PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"},
},
}}
}
type semanticFingerprintProvider interface {
SemanticConfigFingerprint(*Env) (manifest.SemanticConfigFingerprint, error)
}
func assertSemanticFingerprintChanges(t *testing.T, provider semanticFingerprintProvider, base *Env, tests []struct {
name string
mutate func(*Env)
}) {
t.Helper()
want, err := provider.SemanticConfigFingerprint(base)
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run("semantic "+test.name, func(t *testing.T) {
candidate := initialSemanticEnv()
test.mutate(candidate)
got, err := provider.SemanticConfigFingerprint(candidate)
if err != nil {
t.Fatal(err)
}
if want.Equal(got) {
t.Fatalf("semantic change %q retained fingerprint %q", test.name, got.Digest)
}
})
}
}
func assertSemanticFingerprintUnchanged(t *testing.T, provider semanticFingerprintProvider, base *Env, tests []struct {
name string
mutate func(*Env)
}) {
t.Helper()
want, err := provider.SemanticConfigFingerprint(base)
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run("operational "+test.name, func(t *testing.T) {
candidate := initialSemanticEnv()
test.mutate(candidate)
got, err := provider.SemanticConfigFingerprint(candidate)
if err != nil {
t.Fatal(err)
}
if !want.Equal(got) {
t.Fatalf("operational change %q changed fingerprint: want %#v got %#v", test.name, want, got)
}
})
}
}
func intPointer(value int) *int { return &value }
func floatPointer(value float64) *float64 { return &value }
func boolPointer(value bool) *bool { return &value }