- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
410 lines
12 KiB
Go
410 lines
12 KiB
Go
package configrefresh_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"iop/apps/edge/internal/configrefresh"
|
||
)
|
||
|
||
// TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required.
|
||
func TestClassifyListenPortRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:19090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-s08",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
|
||
}
|
||
found := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("server.listen restart_required change not found in: %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected.
|
||
func TestRefreshInvalidYAMLRejected(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated")
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: invalidPath,
|
||
RequestID: "test-s09-yaml",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRejected {
|
||
t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status)
|
||
}
|
||
if candidate != nil {
|
||
t.Errorf("expected nil candidate for rejected result")
|
||
}
|
||
}
|
||
|
||
// TestClassifyServeNormalizedRefreshApplied verifies the archived bug:
|
||
// when current config is serve-normalized (logging.path absolute,
|
||
// bootstrap.artifact_dir absolute) and candidate uses source-level defaults
|
||
// (empty logging.path, relative bootstrap.artifact_dir) with only provider
|
||
// capacity changes, the result must be applied, NOT restart_required.
|
||
func TestClassifyServeNormalizedRefreshApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// Source-level YAML with empty logging.path and relative artifact_dir.
|
||
// Only capacity differs (2 → 8).
|
||
sourceYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
bootstrap:
|
||
artifact_dir: artifacts
|
||
logging:
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
// Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
bootstrap:
|
||
artifact_dir: artifacts
|
||
logging:
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 8
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", sourceYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
// Build current as serve-normalized: explicit absolute paths.
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
// Verify that the current config actually has normalized paths.
|
||
if current.Logging.Path == "" {
|
||
t.Fatalf("expected normalized logging.path, got empty")
|
||
}
|
||
if current.Bootstrap.ArtifactDir == "" {
|
||
t.Fatalf("expected normalized bootstrap.artifact_dir, got empty")
|
||
}
|
||
|
||
// Evaluate the candidate via LoadCandidate (which now applies normalization).
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-serve-normalized",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
|
||
// Key assertion: capacity-only refresh must NOT be restart_required.
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
|
||
}
|
||
|
||
// Ensure no restart_required changes for logging or bootstrap.artifact_dir.
|
||
for _, c := range result.Changes {
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
|
||
}
|
||
}
|
||
|
||
// Verify the capacity change is captured as applied.
|
||
foundCapacity := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
|
||
foundCapacity = true
|
||
if c.Previous != "2" || c.Next != "8" {
|
||
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
|
||
}
|
||
}
|
||
}
|
||
if !foundCapacity {
|
||
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
|
||
}
|
||
|
||
// Verify the candidate is also normalized.
|
||
if candidate == nil {
|
||
t.Fatalf("expected non-nil candidate")
|
||
}
|
||
if candidate.Logging.Path == "" {
|
||
t.Fatalf("expected normalized candidate logging.path, got empty")
|
||
}
|
||
}
|
||
|
||
// TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when
|
||
// both current and candidate use the same explicit relative logging.path
|
||
// (e.g. "./logs/edge.log") and only provider capacity differs, the refresh
|
||
// is classified as applied – NOT restart_required.
|
||
//
|
||
// This is the regression test for the archived bug where applyRuntimeNormalization
|
||
// called filepath.Clean on explicit relative paths, producing a false diff
|
||
// against the serve-side value that preserves "./logs/edge.log" unchanged.
|
||
func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// Current config with explicit relative logging.path – mimics what serve
|
||
// holds in memory after loading YAML with logging.path: "./logs/edge.log".
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
logging:
|
||
path: "./logs/edge.log"
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
// Candidate: same explicit relative logging.path, only capacity changes.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
logging:
|
||
path: "./logs/edge.log"
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 8
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
// buildNormalizedCurrent preserves the explicit relative logging.path.
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
// Verify the current config still has the explicit relative path.
|
||
if current.Logging.Path != "./logs/edge.log" {
|
||
t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path)
|
||
}
|
||
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-explicit-relative-logpath",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
|
||
// Key assertion: capacity-only refresh with same explicit relative path must be applied.
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
|
||
}
|
||
|
||
// Ensure no restart_required changes at all.
|
||
for _, c := range result.Changes {
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
|
||
}
|
||
}
|
||
|
||
// Verify the capacity change is captured as applied.
|
||
foundCapacity := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
|
||
foundCapacity = true
|
||
if c.Previous != "2" || c.Next != "8" {
|
||
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
|
||
}
|
||
}
|
||
}
|
||
if !foundCapacity {
|
||
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
|
||
}
|
||
|
||
// Candidate should also preserve the explicit relative path.
|
||
if candidate == nil {
|
||
t.Fatalf("expected non-nil candidate")
|
||
}
|
||
if candidate.Logging.Path != "./logs/edge.log" {
|
||
t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path)
|
||
}
|
||
}
|
||
|
||
// TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run
|
||
// on the same candidate produces no diff.
|
||
func TestClassifyDryRunTwiceNoDiff(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
baseYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
`
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 4
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
|
||
// First dry-run: should detect capacity change.
|
||
result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-first-dryrun",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("first Evaluate: %v", err)
|
||
}
|
||
if result1.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status)
|
||
}
|
||
|
||
// Second dry-run with the same candidate loaded again.
|
||
// The candidate path still exists, so LoadCandidate re-reads the same YAML.
|
||
result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-second-dryrun",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("second Evaluate: %v", err)
|
||
}
|
||
|
||
// Both results should be identical (same changes).
|
||
if len(result1.Changes) != len(result2.Changes) {
|
||
t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v",
|
||
len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes)
|
||
}
|
||
|
||
// Both candidates should be normalized identically.
|
||
if cand1 == nil || cand2 == nil {
|
||
t.Fatalf("expected non-nil candidates")
|
||
}
|
||
if cand1.Logging.Path != cand2.Logging.Path {
|
||
t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
|
||
}
|
||
}
|