mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
581 lines
17 KiB
Go
581 lines
17 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/boshu2/agentops/cli/internal/subprocess"
|
|
)
|
|
|
|
func TestRunSuiteArtifactCheckPasses(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeEvalFile(t, filepath.Join(dir, "fixture.txt"), "alpha\nneedle\nomega\n")
|
|
suitePath := writeEvalSuite(t, dir, `{
|
|
"schema_version": 1,
|
|
"id": "fixture.pass",
|
|
"name": "Fixture pass",
|
|
"domain": "cli",
|
|
"visibility": "public_canary",
|
|
"tier": "deterministic",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "contains",
|
|
"title": "fixture contains needle",
|
|
"kind": "artifact_check",
|
|
"objective": "Verify static fixtures are scored offline.",
|
|
"expectations": [
|
|
{"type": "artifact_contains", "target": "fixture.txt", "value": "needle"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
run, err := RunSuite(RunOptions{
|
|
SuitePath: suitePath,
|
|
RunID: "run-pass",
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSuite returned error: %v", err)
|
|
}
|
|
|
|
if run.Status != StatusPass || run.Verdict != VerdictPass {
|
|
t.Fatalf("status/verdict = %s/%s, want pass/pass", run.Status, run.Verdict)
|
|
}
|
|
if run.AggregateScore != 1 {
|
|
t.Fatalf("aggregate_score = %v, want 1", run.AggregateScore)
|
|
}
|
|
if got := run.DimensionScores["correctness"]; got != 1 {
|
|
t.Fatalf("correctness score = %v, want 1", got)
|
|
}
|
|
if len(run.CaseResults) != 1 || run.CaseResults[0].Score != 1 {
|
|
t.Fatalf("case results = %+v, want one passing case", run.CaseResults)
|
|
}
|
|
if len(run.Suite.SHA256) != 64 {
|
|
t.Fatalf("suite sha length = %d, want 64", len(run.Suite.SHA256))
|
|
}
|
|
}
|
|
|
|
func TestGitOutputContextIntentionallyCollapsesCleanupFailure(t *testing.T) {
|
|
cleanupErr := errors.New("cleanup sentinel")
|
|
got := gitOutputContextWithRunner(
|
|
context.Background(),
|
|
func(context.Context, subprocess.Command) (subprocess.Result, error) {
|
|
return subprocess.Result{
|
|
Stdout: subprocess.Output{Prefix: []byte("must-not-escape\n"), TotalBytes: 16},
|
|
Cleanup: subprocess.CleanupOutcome{
|
|
Status: subprocess.CleanupFailed,
|
|
Attempted: true,
|
|
Error: cleanupErr.Error(),
|
|
},
|
|
}, cleanupErr
|
|
},
|
|
t.TempDir(),
|
|
"rev-parse",
|
|
"--short=12",
|
|
"HEAD",
|
|
)
|
|
if got != "" {
|
|
t.Fatalf("gitOutputContextWithRunner = %q, want unavailable metadata", got)
|
|
}
|
|
}
|
|
|
|
// TestRunSuiteRerunIsDeterministic asserts that RunSuite over an artifact_check
|
|
// suite produces bit-equal CaseResult outputs across reruns, ignoring fields
|
|
// that are inherently per-run (RunID, wall-clock timestamps, and per-case
|
|
// duration_ms). This is the engine's determinism contract: deterministic-tier
|
|
// suites must be safe to baseline because every rerun is reproducible.
|
|
func TestRunSuiteRerunIsDeterministic(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeEvalFile(t, filepath.Join(dir, "fixture.txt"), "alpha\nneedle\nomega\n")
|
|
suitePath := writeEvalSuite(t, dir, `{
|
|
"schema_version": 1,
|
|
"id": "fixture.determinism",
|
|
"name": "Fixture determinism",
|
|
"domain": "cli",
|
|
"visibility": "public_canary",
|
|
"tier": "deterministic",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "contains",
|
|
"title": "fixture contains needle",
|
|
"kind": "artifact_check",
|
|
"objective": "Verify reruns are deterministic.",
|
|
"expectations": [
|
|
{"type": "artifact_contains", "target": "fixture.txt", "value": "needle"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
first, err := RunSuite(RunOptions{SuitePath: suitePath, RunID: "rerun-1", Now: fixedEvalTime})
|
|
if err != nil {
|
|
t.Fatalf("RunSuite #1: %v", err)
|
|
}
|
|
second, err := RunSuite(RunOptions{SuitePath: suitePath, RunID: "rerun-2", Now: fixedEvalTime})
|
|
if err != nil {
|
|
t.Fatalf("RunSuite #2: %v", err)
|
|
}
|
|
|
|
if got, want := len(first.CaseResults), len(second.CaseResults); got != want {
|
|
t.Fatalf("case_results len: got %d, want %d", got, want)
|
|
}
|
|
if first.AggregateScore != second.AggregateScore {
|
|
t.Fatalf("aggregate_score: %v != %v", first.AggregateScore, second.AggregateScore)
|
|
}
|
|
if first.Suite.SHA256 != second.Suite.SHA256 {
|
|
t.Fatalf("suite sha drift: %q != %q", first.Suite.SHA256, second.Suite.SHA256)
|
|
}
|
|
|
|
a, b := normalizeForDeterminism(first), normalizeForDeterminism(second)
|
|
aJSON, err := json.Marshal(a)
|
|
if err != nil {
|
|
t.Fatalf("marshal first: %v", err)
|
|
}
|
|
bJSON, err := json.Marshal(b)
|
|
if err != nil {
|
|
t.Fatalf("marshal second: %v", err)
|
|
}
|
|
if string(aJSON) != string(bJSON) {
|
|
t.Fatalf("rerun drift after normalization:\nfirst: %s\nsecond: %s", aJSON, bJSON)
|
|
}
|
|
}
|
|
|
|
// normalizeForDeterminism strips fields that are expected to vary across runs
|
|
// even when the suite logic is deterministic: run id, wall-clock timestamps,
|
|
// and per-case duration measurements.
|
|
func normalizeForDeterminism(r *RunRecord) *RunRecord {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
out := *r
|
|
out.RunID = ""
|
|
out.StartedAt = time.Time{}
|
|
out.CompletedAt = nil
|
|
cases := make([]CaseResult, len(out.CaseResults))
|
|
copy(cases, out.CaseResults)
|
|
for i := range cases {
|
|
cases[i].DurationMS = 0
|
|
}
|
|
out.CaseResults = cases
|
|
return &out
|
|
}
|
|
|
|
func TestRunSuiteCommandCasePasses(t *testing.T) {
|
|
if os.Getenv("GO_WANT_EVAL_HELPER_PROCESS") == "1" {
|
|
fmt.Print(`{"ok":true,"message":"hello from helper"}`)
|
|
os.Exit(0)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
suitePath := writeEvalSuite(t, dir, fmt.Sprintf(`{
|
|
"schema_version": 1,
|
|
"id": "command.pass",
|
|
"name": "Command pass",
|
|
"domain": "cli",
|
|
"visibility": "public_canary",
|
|
"tier": "deterministic",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "helper",
|
|
"title": "helper command emits JSON",
|
|
"kind": "command",
|
|
"objective": "Run a local deterministic command.",
|
|
"runtime": "shell",
|
|
"inputs": {
|
|
"argv": [%q, "-test.run=TestRunSuiteCommandCasePasses"],
|
|
"env": {"GO_WANT_EVAL_HELPER_PROCESS": "1"}
|
|
},
|
|
"expectations": [
|
|
{"type": "exit_code", "value": 0},
|
|
{"type": "stdout_contains", "value": "hello"},
|
|
{"type": "json_path", "target": "stdout.ok", "value": true}
|
|
]
|
|
}
|
|
]
|
|
}`, exe))
|
|
|
|
run, err := RunSuite(RunOptions{
|
|
SuitePath: suitePath,
|
|
RunID: "run-command",
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSuite returned error: %v", err)
|
|
}
|
|
if run.Status != StatusPass {
|
|
t.Fatalf("status = %s, want pass; result=%+v", run.Status, run.CaseResults)
|
|
}
|
|
if run.Runtime.Name != RuntimeShell {
|
|
t.Fatalf("runtime = %s, want shell", run.Runtime.Name)
|
|
}
|
|
if cleanup := run.CaseResults[0].Cleanup; cleanup == nil || cleanup.Status != subprocess.CleanupCompleted || !cleanup.Completed {
|
|
t.Fatalf("cleanup = %#v, want completed command cleanup", cleanup)
|
|
}
|
|
}
|
|
|
|
func TestRunSuiteHighOutputReportsTruncationAndPreservesEdges(t *testing.T) {
|
|
if os.Getenv("GO_WANT_EVAL_HIGH_OUTPUT_HELPER") == "1" {
|
|
fmt.Print("EVAL_HEAD")
|
|
fmt.Print(strings.Repeat("x", 2*1024*1024))
|
|
fmt.Print("EVAL_TAIL")
|
|
os.Exit(0)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
suitePath := writeEvalSuite(t, dir, fmt.Sprintf(`{
|
|
"schema_version": 1,
|
|
"id": "command.high-output",
|
|
"name": "Command high output",
|
|
"domain": "cli",
|
|
"visibility": "public_canary",
|
|
"tier": "deterministic",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "bounded",
|
|
"title": "high output is bounded while streamed",
|
|
"kind": "command",
|
|
"objective": "Retain useful edges and expose truncation.",
|
|
"runtime": "shell",
|
|
"inputs": {
|
|
"argv": [%q, "-test.run=TestRunSuiteHighOutputReportsTruncationAndPreservesEdges"],
|
|
"env": {"GO_WANT_EVAL_HIGH_OUTPUT_HELPER": "1"}
|
|
},
|
|
"expectations": [
|
|
{"type": "exit_code", "value": 0},
|
|
{"type": "stdout_contains", "value": "EVAL_HEAD"},
|
|
{"type": "stdout_contains", "value": "EVAL_TAIL"}
|
|
]
|
|
}
|
|
]
|
|
}`, executable))
|
|
|
|
run, err := RunSuiteContext(context.Background(), RunOptions{SuitePath: suitePath, RunID: "run-high-output", Now: fixedEvalTime})
|
|
if err != nil {
|
|
t.Fatalf("RunSuiteContext: %v", err)
|
|
}
|
|
if run.Status != StatusPass {
|
|
t.Fatalf("status = %s, want pass; results=%+v", run.Status, run.CaseResults)
|
|
}
|
|
if got := strings.Join(run.CaseResults[0].Diagnostics, "\n"); !strings.Contains(got, "stdout truncated after") {
|
|
t.Fatalf("diagnostics lack truncation telemetry: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSuiteExpectationFailureFailsRun(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeEvalFile(t, filepath.Join(dir, "fixture.txt"), "safe text\n")
|
|
suitePath := writeEvalSuite(t, dir, `{
|
|
"schema_version": 1,
|
|
"id": "fixture.fail",
|
|
"name": "Fixture fail",
|
|
"domain": "cli",
|
|
"visibility": "public_canary",
|
|
"tier": "deterministic",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "missing",
|
|
"title": "fixture lacks needle",
|
|
"kind": "artifact_check",
|
|
"objective": "Required expectations fail the case.",
|
|
"expectations": [
|
|
{"type": "artifact_contains", "target": "fixture.txt", "value": "needle"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
run, err := RunSuite(RunOptions{SuitePath: suitePath, RunID: "run-fail", Now: fixedEvalTime})
|
|
if err != nil {
|
|
t.Fatalf("RunSuite returned error: %v", err)
|
|
}
|
|
if run.Status != StatusFail || run.Verdict != VerdictFail {
|
|
t.Fatalf("status/verdict = %s/%s, want fail/fail", run.Status, run.Verdict)
|
|
}
|
|
if run.CaseResults[0].FailureMessage == "" {
|
|
t.Fatalf("expected failure message, got %+v", run.CaseResults[0])
|
|
}
|
|
}
|
|
|
|
func TestRunSuiteRejectsLiveTier(t *testing.T) {
|
|
dir := t.TempDir()
|
|
suitePath := writeEvalSuite(t, dir, `{
|
|
"schema_version": 1,
|
|
"id": "live.out.of.scope",
|
|
"name": "Live suite",
|
|
"domain": "runtime",
|
|
"visibility": "public_canary",
|
|
"tier": "live",
|
|
"scoring": {
|
|
"aggregate_threshold": 1,
|
|
"dimensions": [
|
|
{"name": "correctness", "weight": 1, "threshold": 1}
|
|
]
|
|
},
|
|
"baseline_policy": {"mode": "none"},
|
|
"cases": [
|
|
{
|
|
"id": "prompt",
|
|
"title": "runtime prompt",
|
|
"kind": "runtime_prompt",
|
|
"objective": "Live adapters are not deterministic.",
|
|
"runtime": "claude",
|
|
"expectations": [
|
|
{"type": "manual_review"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
if _, err := RunSuite(RunOptions{SuitePath: suitePath, RunID: "run-live", Now: fixedEvalTime}); err == nil {
|
|
t.Fatal("RunSuite succeeded for live tier, want error")
|
|
}
|
|
}
|
|
|
|
func TestCompareRunsMarksRegression(t *testing.T) {
|
|
baseline := minimalRunRecord("baseline-run", 0.90, map[Dimension]float64{DimensionCorrectness: 0.90})
|
|
candidate := minimalRunRecord("candidate-run", 0.70, map[Dimension]float64{DimensionCorrectness: 0.70})
|
|
|
|
compared, err := CompareRuns(candidate, baseline, CompareOptions{})
|
|
if err != nil {
|
|
t.Fatalf("CompareRuns returned error: %v", err)
|
|
}
|
|
|
|
if compared.Verdict != VerdictRegression || compared.Status != StatusPass {
|
|
t.Fatalf("status/verdict = %s/%s, want pass/regression", compared.Status, compared.Verdict)
|
|
}
|
|
if compared.BaselineComparison == nil {
|
|
t.Fatal("expected baseline comparison")
|
|
}
|
|
if got := compared.BaselineComparison.AggregateDelta; got != -0.2 {
|
|
t.Fatalf("aggregate delta = %v, want -0.2", got)
|
|
}
|
|
if len(compared.BaselineComparison.Regressions) != 1 {
|
|
t.Fatalf("regressions = %+v, want one regression", compared.BaselineComparison.Regressions)
|
|
}
|
|
}
|
|
|
|
func TestPromoteBaselineWritesRunRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outputPath := filepath.Join(dir, "baseline.json")
|
|
run := minimalRunRecord("candidate-run", 1, map[Dimension]float64{DimensionCorrectness: 1})
|
|
|
|
promoted, err := PromoteBaseline(run, BaselineOptions{
|
|
OutputPath: outputPath,
|
|
PromotedBy: "tester",
|
|
Rationale: "stable deterministic canary",
|
|
Now: fixedEvalTime,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PromoteBaseline returned error: %v", err)
|
|
}
|
|
|
|
if promoted.Baseline == nil || promoted.Baseline.Mode != BaselineModePromote {
|
|
t.Fatalf("baseline metadata = %+v, want promote", promoted.Baseline)
|
|
}
|
|
if promoted.Baseline.PromotedBy != "tester" {
|
|
t.Fatalf("promoted_by = %q, want tester", promoted.Baseline.PromotedBy)
|
|
}
|
|
loaded, err := LoadRun(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadRun(%s): %v", outputPath, err)
|
|
}
|
|
if loaded.RunID != promoted.RunID {
|
|
t.Fatalf("loaded run_id = %q, want %q", loaded.RunID, promoted.RunID)
|
|
}
|
|
}
|
|
|
|
func TestPromoteBaselineEncodesColonInDerivedFilename(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
run := minimalRunRecord("Run:candidate", 1, map[Dimension]float64{DimensionCorrectness: 1})
|
|
promoted, err := PromoteBaseline(run, BaselineOptions{WorkDir: workDir, Now: fixedEvalTime})
|
|
if err != nil {
|
|
t.Fatalf("PromoteBaseline: %v", err)
|
|
}
|
|
base := filepath.Base(promoted.Baseline.BaselinePath)
|
|
if strings.Contains(base, ":") || !strings.Contains(base, "%3A") {
|
|
t.Fatalf("baseline filename = %q, want reversible colon encoding", base)
|
|
}
|
|
}
|
|
|
|
func TestCollectGitRecordPreservesDirtyPathNames(t *testing.T) {
|
|
dir := t.TempDir()
|
|
runGit(t, dir, "init")
|
|
runGit(t, dir, "config", "user.email", "tester@example.com")
|
|
runGit(t, dir, "config", "user.name", "Tester")
|
|
runGit(t, dir, "config", "commit.gpgsign", "false")
|
|
runGit(t, dir, "config", "tag.gpgsign", "false")
|
|
writeEvalFile(t, filepath.Join(dir, "tracked.txt"), "before\n")
|
|
runGit(t, dir, "add", "tracked.txt")
|
|
runGit(t, dir, "commit", "-m", "initial")
|
|
writeEvalFile(t, filepath.Join(dir, "tracked.txt"), "after\n")
|
|
writeEvalFile(t, filepath.Join(dir, "new.txt"), "new\n")
|
|
|
|
record := collectGitRecord(dir)
|
|
|
|
if !record.Dirty {
|
|
t.Fatal("dirty = false, want true")
|
|
}
|
|
want := []string{"new.txt", "tracked.txt"}
|
|
if fmt.Sprint(record.DirtyPaths) != fmt.Sprint(want) {
|
|
t.Fatalf("dirty paths = %v, want %v", record.DirtyPaths, want)
|
|
}
|
|
}
|
|
|
|
func fixedEvalTime() time.Time {
|
|
return time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func writeEvalSuite(t *testing.T, dir, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, "suite.json")
|
|
writeEvalFile(t, path, body)
|
|
return path
|
|
}
|
|
|
|
func writeEvalFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// scrubbedGitEnv returns os.Environ() minus git's repo-discovery overrides
|
|
// (GIT_DIR et al.): under a hook-leaked GIT_DIR, cmd.Dir alone does NOT scope
|
|
// a mutation — `git config user.name Tester` would write the LEAKED repo's
|
|
// shared config (age-gate-scripts-worktree-gitdir-p62wo; .claude/rules/go.md
|
|
// test isolation).
|
|
func scrubbedGitEnv() []string {
|
|
env := make([]string, 0, len(os.Environ()))
|
|
for _, entry := range os.Environ() {
|
|
if strings.HasPrefix(entry, "GIT_DIR=") ||
|
|
strings.HasPrefix(entry, "GIT_WORK_TREE=") ||
|
|
strings.HasPrefix(entry, "GIT_COMMON_DIR=") ||
|
|
strings.HasPrefix(entry, "GIT_INDEX_FILE=") {
|
|
continue
|
|
}
|
|
env = append(env, entry)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = scrubbedGitEnv()
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
|
|
func minimalRunRecord(runID string, aggregate float64, dimensions map[Dimension]float64) *RunRecord {
|
|
return &RunRecord{
|
|
SchemaVersion: 1,
|
|
RunID: runID,
|
|
Suite: SuiteRef{
|
|
ID: "fixture.pass",
|
|
Path: "suite.json",
|
|
Visibility: VisibilityPublicCanary,
|
|
Tier: TierDeterministic,
|
|
},
|
|
StartedAt: fixedEvalTime(),
|
|
Status: StatusPass,
|
|
Verdict: VerdictPass,
|
|
Git: GitRecord{
|
|
CandidateRef: "test",
|
|
CandidateSHA: "0000000",
|
|
Dirty: false,
|
|
},
|
|
Runtime: RuntimeRecord{
|
|
Name: RuntimeStatic,
|
|
Live: false,
|
|
},
|
|
Environment: EnvironmentRecord{
|
|
ScrubbedEnvPrefixes: []string{"AGENTOPS_RPI_RUNTIME"},
|
|
IsolatedHome: false,
|
|
IsolatedCodexHome: false,
|
|
NetworkAccess: NetworkDisabled,
|
|
},
|
|
CaseResults: []CaseResult{
|
|
{
|
|
ID: "case",
|
|
Status: StatusPass,
|
|
Score: aggregate,
|
|
DimensionScores: dimensions,
|
|
},
|
|
},
|
|
AggregateScore: aggregate,
|
|
DimensionScores: dimensions,
|
|
}
|
|
}
|
|
|
|
func TestRunRecordJSONRoundTrip(t *testing.T) {
|
|
run := minimalRunRecord("roundtrip", 1, map[Dimension]float64{DimensionCorrectness: 1})
|
|
data, err := json.Marshal(run)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
var decoded RunRecord
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if decoded.RunID != run.RunID {
|
|
t.Fatalf("decoded run_id = %q, want %q", decoded.RunID, run.RunID)
|
|
}
|
|
}
|