feat(job-array-one-based-index): parse submit index expressions (task 6.1)

ParseSubmitIndexExpr accepts only name[1-N] and name[1] (leading zeros
allowed) and otherwise reports the lowest-numbered broken rule with its
specific message: interactive arrays (2.1), invalid name or malformed
expression (2.2), lists/steps (2.3), %N (2.4), start 0 (2.5), reversed
(2.6), start above 1 (2.7), above the limit (2.8). Oversized numbers
saturate for the bound checks while reversal compares the digit
strings, so the rule order holds for any input. Not yet wired into
CreateJob (task 6.2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-01 11:47:23 +00:00
parent 3a7e3890e2
commit 0318ac5f44
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
3 changed files with 359 additions and 1 deletions

View file

@ -0,0 +1,153 @@
package controllers
import (
"strings"
"testing"
)
// TestParseSubmitIndexExpr covers requirements 1.5 and 2.1-2.9 for the
// submit-time index expression parser (design: ApiSubmitExpr).
func TestParseSubmitIndexExpr(t *testing.T) {
longName := strings.Repeat("a", 4094)
tooLongName := strings.Repeat("a", 4095)
type want struct {
arrayName string
n int
rule string // "" means success
msgHas []string // substrings the error message must contain
}
cases := []struct {
name string
input string
interactive bool
limit int
want want
}{
// --- success (1.1, 1.2) ---
{"range 1-N", "arr[1-3]", false, 1000, want{arrayName: "arr", n: 3}},
{"single 1", "arr[1]", false, 1000, want{arrayName: "arr", n: 1}},
{"range 1-1", "arr[1-1]", false, 1000, want{arrayName: "arr", n: 1}},
{"name charset", "a.B_c-9[1-2]", false, 1000, want{arrayName: "a.B_c-9", n: 2}},
{"name 4094 chars", longName + "[1-2]", false, 1000, want{arrayName: longName, n: 2}},
// --- leading zeros (1.5) ---
{"leading zeros range", "arr[001-0003]", false, 1000, want{arrayName: "arr", n: 3}},
{"leading zeros single", "arr[01]", false, 1000, want{arrayName: "arr", n: 1}},
{"leading zeros limit", "arr[1-0001000]", false, 1000, want{arrayName: "arr", n: 1000}},
{"leading zeros zero", "arr[00-3]", false, 1000, want{rule: "2.5"}},
// --- limit edge (2.8) ---
{"limit exact", "arr[1-1000]", false, 1000, want{arrayName: "arr", n: 1000}},
{"limit plus one", "arr[1-1001]", false, 1000, want{rule: "2.8", msgHas: []string{"1000"}}},
{"limit 32767 exact", "arr[1-32767]", false, 32767, want{arrayName: "arr", n: 32767}},
{"limit 32767 plus one", "arr[1-32768]", false, 32767, want{rule: "2.8", msgHas: []string{"32767"}}},
{"limit huge", "arr[1-99999999999999999999]", false, 1000, want{rule: "2.8", msgHas: []string{"1000"}}},
{"limit custom", "arr[1-6]", false, 5, want{rule: "2.8", msgHas: []string{"5"}}},
// --- 2.1 interactive ---
{"interactive array", "arr[1-3]", true, 1000, want{rule: "2.1", msgHas: []string{"interactive jobs cannot be arrays"}}},
{"interactive malformed", "arr[", true, 1000, want{rule: "2.1", msgHas: []string{"interactive jobs cannot be arrays"}}},
{"interactive bad name", "a b]", true, 1000, want{rule: "2.1"}},
// --- 2.2 invalid name ---
{"empty name", "[1-3]", false, 1000, want{rule: "2.2", msgHas: []string{"'[1-3]'", "array name is invalid"}}},
{"bad char in name", "a*b[1-3]", false, 1000, want{rule: "2.2", msgHas: []string{"'a*b[1-3]'", "array name is invalid"}}},
{"space in name", "a b[1-3]", false, 1000, want{rule: "2.2", msgHas: []string{"array name is invalid"}}},
{"leading space", " arr[1-3]", false, 1000, want{rule: "2.2", msgHas: []string{"array name is invalid"}}},
{"name too long", tooLongName + "[1-2]", false, 1000, want{rule: "2.2", msgHas: []string{"array name is invalid"}}},
{"only close bracket bad name", "a*b]", false, 1000, want{rule: "2.2", msgHas: []string{"array name is invalid"}}},
{"bad name beats list", "a/b[1,2]", false, 1000, want{rule: "2.2", msgHas: []string{"array name is invalid"}}},
// --- 2.2 malformed ---
{"only close bracket", "arr]", false, 1000, want{rule: "2.2", msgHas: []string{"'arr]'", "array name is invalid"}}},
{"unterminated", "arr[1-3", false, 1000, want{rule: "2.2", msgHas: []string{"'arr[1-3'", "malformed"}}},
{"empty brackets", "arr[]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"trailing text", "arr[1-3]x", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"trailing space", "arr[1-3] ", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"inner space", "arr[1 -3]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"sign", "arr[+1-3]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"negative", "arr[-1]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"empty item", "arr[1,,2]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"trailing comma", "arr[1,]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"missing end", "arr[1-]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"missing step", "arr[1-3:]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"step without range", "arr[1:2]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"pct without number", "arr[1-3]%", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"pct trailing", "arr[1-3]%2x", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"two brackets", "arr[1][2]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"nested bracket", "arr[[1]]", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"malformed beats list", "arr[1,2", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
{"malformed beats zero", "arr[0", false, 1000, want{rule: "2.2", msgHas: []string{"malformed"}}},
// --- 2.3 lists / steps ---
{"list", "arr[1,2]", false, 1000, want{rule: "2.3", msgHas: []string{"only a single range name[1-N] is supported"}}},
{"list of ranges", "arr[1-3,5-6]", false, 1000, want{rule: "2.3", msgHas: []string{"only a single range name[1-N] is supported"}}},
{"step", "arr[1-10:2]", false, 1000, want{rule: "2.3", msgHas: []string{"only a single range name[1-N] is supported"}}},
{"precedence 0-0:2", "arr[0-0:2]", false, 1000, want{rule: "2.3"}},
{"list beats pct", "arr[1,2]%3", false, 1000, want{rule: "2.3"}},
// --- 2.4 running limit ---
{"pct", "arr[1-3]%2", false, 1000, want{rule: "2.4", msgHas: []string{"running limits are not supported"}}},
{"pct beats zero", "arr[0-3]%2", false, 1000, want{rule: "2.4"}},
// --- 2.5 start 0 ---
{"start zero", "arr[0-3]", false, 1000, want{rule: "2.5", msgHas: []string{"element numbers start at 1"}}},
{"single zero", "arr[0]", false, 1000, want{rule: "2.5"}},
{"zero beats reversed", "arr[0-0]", false, 1000, want{rule: "2.5"}},
{"precedence 0-huge", "arr[0-99999999999999999999]", false, 1000, want{rule: "2.5"}},
// --- 2.6 reversed ---
{"reversed", "arr[3-1]", false, 1000, want{rule: "2.6", msgHas: []string{"reversed"}}},
{"precedence huge-5", "arr[99999999999999999999-5]", false, 1000, want{rule: "2.6"}},
{"reversed beats limit", "arr[5000-2000]", false, 1000, want{rule: "2.6"}},
// --- 2.7 start > 1 ---
{"single 5", "arr[5]", false, 1000, want{rule: "2.7", msgHas: []string{"only name[1-N] and name[1] are currently supported"}}},
{"start above 1", "arr[1001-2000]", false, 1000, want{rule: "2.7", msgHas: []string{"only name[1-N] and name[1] are currently supported"}}},
{"start 2", "arr[2-3]", false, 1000, want{rule: "2.7"}},
{"precedence huge single", "arr[99999999999999999999]", false, 1000, want{rule: "2.7"}},
{"huge range equal", "arr[99999999999999999999-99999999999999999999]", false, 1000, want{rule: "2.7"}},
{"huge reversed two oversized", "arr[99999999999999999999-99999999999999999998]", false, 1000, want{rule: "2.6"}},
{"huge reversed just above 2^40", "arr[1099511627777-1099511627776]", false, 1000, want{rule: "2.6"}},
{"huge ascending two oversized", "arr[99999999999999999998-99999999999999999999]", false, 1000, want{rule: "2.7"}},
{"huge equal after leading zeros", "arr[0099999999999999999999-99999999999999999999]", false, 1000, want{rule: "2.7"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
arrayName, n, isArray, err := ParseSubmitIndexExpr(tc.input, tc.interactive, tc.limit)
if tc.want.rule == "" {
if err != nil {
t.Fatalf("ParseSubmitIndexExpr(%q) unexpected error: rule %s: %s", tc.input, err.Rule, err.Msg)
}
if !isArray || arrayName != tc.want.arrayName || n != tc.want.n {
t.Fatalf("ParseSubmitIndexExpr(%q) = (%q, %d, %v), want (%q, %d, true)",
tc.input, arrayName, n, isArray, tc.want.arrayName, tc.want.n)
}
return
}
if err == nil {
t.Fatalf("ParseSubmitIndexExpr(%q) = (%q, %d, %v, nil), want rule %s",
tc.input, arrayName, n, isArray, tc.want.rule)
}
if err.Rule != tc.want.rule {
t.Fatalf("ParseSubmitIndexExpr(%q) rule = %s (%s), want %s", tc.input, err.Rule, err.Msg, tc.want.rule)
}
if isArray || n != 0 || arrayName != "" {
t.Fatalf("ParseSubmitIndexExpr(%q) returned (%q, %d, %v) alongside an error", tc.input, arrayName, n, isArray)
}
if err.Error() != err.Msg {
t.Fatalf("Error() = %q, want Msg %q", err.Error(), err.Msg)
}
for _, s := range tc.want.msgHas {
if !strings.Contains(err.Msg, s) {
t.Fatalf("ParseSubmitIndexExpr(%q) message %q does not contain %q", tc.input, err.Msg, s)
}
}
})
}
}
// TestParseSubmitIndexExprErrorAsError checks a non-nil *ArrayExprError
// satisfies the error interface (used by preCheckJobRequest / errors.As).
func TestParseSubmitIndexExprErrorAsError(t *testing.T) {
_, _, _, ae := ParseSubmitIndexExpr("arr[0]", false, 1000)
if ae == nil {
t.Fatal("expected an error for arr[0]")
}
var e error = ae
if !strings.Contains(e.Error(), "element numbers start at 1") {
t.Fatalf("Error() = %q", e.Error())
}
}