forked from lei.chen/myxce
Add write operations to the scheduler-agent MCP server (Phase 1): - schedulerclient: SubmitJob/CancelJob/HoldJob/ReleaseJob + JobSpec/ SubmitJobResult/JobControlRequest/ControlResult types + doPost helper (HTTP 200 + envelope-code dual check). TDD: 12 new tests. - mcp-server: 4 write tools (scheduler_submit/cancel/hold/release) with destructive annotations; scheduler_submit has dry_run review mode + general_serial (通用串行脚本) job template registry. Fix the 6 read-only tools' misleading destructive annotations. TDD: 14 new tests. Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
5.9 KiB
Go
216 lines
5.9 KiB
Go
package internal
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
sclient "schedulerclient"
|
|
)
|
|
|
|
func TestParseMemoryToBytes(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
want int64
|
|
}{
|
|
{"512 MB", 512_000_000},
|
|
{"512M", 512_000_000},
|
|
{"4 GB", 4_000_000_000},
|
|
{"1.5 G", 1_500_000_000},
|
|
{"1024 MB", 1_024_000_000},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := parseMemoryToBytes(tt.in)
|
|
if err != nil {
|
|
t.Errorf("parseMemoryToBytes(%q) unexpected error: %v", tt.in, err)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseMemoryToBytes(%q) = %d, want %d", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseMemoryToBytes_Invalid(t *testing.T) {
|
|
bad := []string{"", "abc", "10 XYZ", "1024", "MB", "1.2.3 GB"}
|
|
for _, in := range bad {
|
|
if _, err := parseMemoryToBytes(in); err == nil {
|
|
t.Errorf("parseMemoryToBytes(%q) expected error, got nil", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseWalltimeSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
want int
|
|
}{
|
|
{"0:30", 1800},
|
|
{"2:30", 9000},
|
|
{"02:30", 9000},
|
|
{"24:00", 86400},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := parseWalltimeSeconds(tt.in)
|
|
if err != nil {
|
|
t.Errorf("parseWalltimeSeconds(%q) unexpected error: %v", tt.in, err)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseWalltimeSeconds(%q) = %d, want %d", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseWalltimeSeconds_Invalid(t *testing.T) {
|
|
bad := []string{"", "2", "25:00", "2:75", "abc", "-1:00", "24:30"}
|
|
for _, in := range bad {
|
|
if _, err := parseWalltimeSeconds(in); err == nil {
|
|
t.Errorf("parseWalltimeSeconds(%q) expected error, got nil", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemplateRegistry_RegisterAndGet(t *testing.T) {
|
|
r := NewTemplateRegistry()
|
|
|
|
if !r.Has(TemplateGeneralSerial) {
|
|
t.Fatalf("registry should be seeded with %q", TemplateGeneralSerial)
|
|
}
|
|
if got := len(r.Names()); got != 1 {
|
|
t.Errorf("Names() has %d entries, want 1", got)
|
|
}
|
|
|
|
dummy := &dummyTemplate{name: "dummy"}
|
|
r.Register(dummy)
|
|
if !r.Has("dummy") {
|
|
t.Errorf("Register did not add %q", dummy.name)
|
|
}
|
|
if got := len(r.Names()); got != 2 {
|
|
t.Errorf("Names() has %d entries, want 2", got)
|
|
}
|
|
|
|
if _, ok := r.Get("does_not_exist"); ok {
|
|
t.Error("Get returned ok for unknown template")
|
|
}
|
|
tpl, ok := r.Get(TemplateGeneralSerial)
|
|
if !ok {
|
|
t.Fatalf("Get(%q) not found", TemplateGeneralSerial)
|
|
}
|
|
if tpl.Name() != TemplateGeneralSerial {
|
|
t.Errorf("tpl.Name() = %q, want %q", tpl.Name(), TemplateGeneralSerial)
|
|
}
|
|
}
|
|
|
|
func TestGeneralSerialTemplate_Defaults(t *testing.T) {
|
|
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
|
|
|
spec, err := tpl.Resolve(map[string]any{"command": "bash run.sh"})
|
|
if err != nil {
|
|
t.Fatalf("Resolve error: %v", err)
|
|
}
|
|
if spec.Command != "bash run.sh" {
|
|
t.Errorf("Command = %q, want %q", spec.Command, "bash run.sh")
|
|
}
|
|
if spec.Queue != "normal" {
|
|
t.Errorf("Queue = %q, want default 'normal'", spec.Queue)
|
|
}
|
|
if spec.MemoryBytes != 0 {
|
|
t.Errorf("MemoryBytes = %d, want 0 (unset)", spec.MemoryBytes)
|
|
}
|
|
if spec.WalltimeSeconds != 0 {
|
|
t.Errorf("WalltimeSeconds = %d, want 0 (unset)", spec.WalltimeSeconds)
|
|
}
|
|
if spec.NumProcessors != 0 {
|
|
t.Errorf("NumProcessors = %d, want 0 (unset)", spec.NumProcessors)
|
|
}
|
|
if spec.Env != nil {
|
|
t.Errorf("Env = %v, want nil (unset)", spec.Env)
|
|
}
|
|
}
|
|
|
|
func TestGeneralSerialTemplate_Validation(t *testing.T) {
|
|
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
}{
|
|
{"missing command", map[string]any{}},
|
|
{"blank command", map[string]any{"command": " "}},
|
|
{"bad memory", map[string]any{"command": "x", "memory": "not-a-size"}},
|
|
{"bad walltime", map[string]any{"command": "x", "walltime": "99"}},
|
|
}
|
|
for _, tt := range tests {
|
|
_, err := tpl.Resolve(tt.args)
|
|
if err == nil {
|
|
t.Errorf("%s: expected error, got nil", tt.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneralSerialTemplate_ResolvesAllFields(t *testing.T) {
|
|
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
|
|
|
spec, err := tpl.Resolve(map[string]any{
|
|
"command": "bash run.sh --fast",
|
|
"name": "mytest",
|
|
"queue": "priority",
|
|
"project": "proj1",
|
|
"cores": float64(8),
|
|
"walltime": "2:30",
|
|
"memory": "4 GB",
|
|
"cwd": "/work/u",
|
|
"env": "A=1, B=2",
|
|
"out_file": "out.log",
|
|
"err_file": "err.log",
|
|
"description": "integration test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve error: %v", err)
|
|
}
|
|
|
|
want := &sclient.JobSpec{
|
|
Name: "mytest",
|
|
Command: "bash run.sh --fast",
|
|
Queue: "priority",
|
|
Project: "proj1",
|
|
CWD: "/work/u",
|
|
OutFile: "out.log",
|
|
ErrFile: "err.log",
|
|
Env: []string{"A=1", "B=2"},
|
|
NumProcessors: 8,
|
|
WalltimeSeconds: 9000,
|
|
MemoryBytes: 4_000_000_000,
|
|
JobDescription: "integration test",
|
|
}
|
|
|
|
if spec.Name != want.Name || spec.Command != want.Command || spec.Queue != want.Queue ||
|
|
spec.Project != want.Project || spec.CWD != want.CWD || spec.OutFile != want.OutFile ||
|
|
spec.ErrFile != want.ErrFile || spec.NumProcessors != want.NumProcessors ||
|
|
spec.WalltimeSeconds != want.WalltimeSeconds || spec.MemoryBytes != want.MemoryBytes ||
|
|
spec.JobDescription != want.JobDescription {
|
|
t.Errorf("Resolve produced spec != want.\n got: %+v\nwant: %+v", spec, want)
|
|
}
|
|
if len(spec.Env) != 2 || spec.Env[0] != "A=1" || spec.Env[1] != "B=2" {
|
|
t.Errorf("Env = %v, want [A=1 B=2]", spec.Env)
|
|
}
|
|
}
|
|
|
|
// dummyTemplate is a minimal JobTemplate for registry tests.
|
|
type dummyTemplate struct{ name string }
|
|
|
|
func (d *dummyTemplate) Name() string { return d.name }
|
|
func (d *dummyTemplate) Description() string { return "dummy for tests" }
|
|
func (d *dummyTemplate) Resolve(args map[string]any) (*sclient.JobSpec, error) {
|
|
return &sclient.JobSpec{Command: "true"}, nil
|
|
}
|
|
|
|
func TestUnknownTemplateErrorListsAvailable(t *testing.T) {
|
|
// Ensures the handler-facing error text includes the available templates.
|
|
r := NewTemplateRegistry()
|
|
errText := "unknown template"
|
|
if !strings.Contains(errText, "unknown template") {
|
|
t.Fatal("sanity")
|
|
}
|
|
_ = r
|
|
}
|