From 2a8490bc9231897f19df69fe7a26971acdeddff6 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Thu, 1 Oct 2026 12:01:22 +0000 Subject: [PATCH] feat(job-array-one-based-index): REST identity and name selection parsers (task 7.1) ParseIdentitySelection (strict written form or base-10 decimal key, optional [k] or [a-b]) and ParseNameSelection (name part may be empty, no whitespace or brackets) apply the shared selection grammar and check order (5.16 syntax, 5.14 width, 5.17 marked decimal without [k], 5.8 alignment only with [...], 5.7 element bound, 5.5 reversed), reporting only the first failure with a rule tag and a message quoting the selection. Runs the shared selection-cases table plus REST-only rows. Not yet wired into the handlers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/job-array-one-based-index/tasks.md | 3 +- src/frontend/apiserver/controllers/jobsel.go | 220 ++++++++++++ .../apiserver/controllers/jobsel_test.go | 335 ++++++++++++++++++ 3 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apiserver/controllers/jobsel.go create mode 100644 src/frontend/apiserver/controllers/jobsel_test.go diff --git a/.kiro/specs/job-array-one-based-index/tasks.md b/.kiro/specs/job-array-one-based-index/tasks.md index d7659332..80fbb912 100644 --- a/.kiro/specs/job-array-one-based-index/tasks.md +++ b/.kiro/specs/job-array-one-based-index/tasks.md @@ -139,7 +139,7 @@ - _Depends: 5.2, 5.4, 6.1_ - [ ] 7. API server: element selection in queries and control -- [ ] 7.1 (P) Parse REST identity and name selections +- [x] 7.1 (P) Parse REST identity and name selections - Parse identity selections (strict written form or base-10 decimal) and name selections (name part may be empty, no whitespace or brackets) under the shared grammar and check order, including the marked-decimal-without-index rejection - Return a rule tag and a message that names the selection as written - Done when the selection unit tests pass for the shared conformance table plus decimal-key cases (marked decimal without index, `1e3`, `-1`) and the name rows @@ -246,3 +246,4 @@ - 5.1: Go helpers: `ParseHumanJobIDStrict(s) (uint64, error)` returns sentinel `ErrJobIDSyntax` / `ErrJobIDWidth` (callers trim and split `[...]` first; no whitespace accepted); `MakeElementID(key uint64, k int) uint64`; `IsArrayCapableKey(key)`; `GenerateArrayJobID(c, a, n)` (non-cgo stub returns errNoCGO). - 5.4: handler fixture in `controllers/jobs_handler_test.go` (`newHandlerFixture`, `seedJob`/`seedOrdinaryJob`/`seedArrayElements`/`seedQueue`, `submit`, `controlByID`, `controlByFilter`, `dispatchedIDs`, `submissions`); submit bodies must be client-style JSON maps (marshalled `models.JobSubmitRequest` breaks on zero CustomTime). Handlers are registered without `RequirePermission`; in-handler RBAC/queue/status checks still run. - 6.1: the design's "saturate oversized numbers" rule alone loses the order of two oversized numbers; `ParseSubmitIndexExpr` compares leading-zero-stripped digit strings for 2.6. Use the same approach for the selection parsers' reversed-range check (7.1, 8.1) if they saturate. Rule tags are strings "2.1".."2.8" on `*ArrayExprError`. +- 7.1: `ParseNameSelection` on a value without `[`/`]` returns `SelBare` with `Name` = the raw untrimmed value (`Raw` is trimmed); 7.2 must match bare names on `Name` and must not treat a bare result as validated. Rule tags on `*SelError` are "5.16", "5.14", "5.17", "5.8", "5.7", "5.5"; decimal keys above uint64 are 5.14. diff --git a/src/frontend/apiserver/controllers/jobsel.go b/src/frontend/apiserver/controllers/jobsel.go new file mode 100644 index 00000000..01e165dc --- /dev/null +++ b/src/frontend/apiserver/controllers/jobsel.go @@ -0,0 +1,220 @@ +package controllers + +import ( + "errors" + "fmt" + "strconv" + "strings" + "unicode" + + "apiserver/backend" +) + +// REST element selections (job-array-one-based-index, design "ApiSel and +// ApiMatch"). ParseIdentitySelection and ParseNameSelection are pure parsers: +// they depend only on the backend ID helpers and the standard library. + +// SelForm is the shape of a parsed selection. +type SelForm int + +const ( + SelBare SelForm = iota // no "[...]" + SelSingle // "[k]" (Lo == Hi == k) + SelRange // "[a-b]" (a == b allowed) +) + +// Selection is one parsed identity or name selection. +type Selection struct { + Raw string // as the user wrote it (trimmed) + Name string // name selections only (job_name / -J) + Key uint64 // identity selections: exact ID (bare) or array ID + Form SelForm + Lo, Hi int // element numbers, Lo == Hi for SelSingle +} + +// SelError is a rejected selection. Rule is the requirement it breaks +// ("5.16", "5.14", "5.17", "5.8", "5.7", "5.5"); Msg names the selection as +// written (trimmed). +type SelError struct { + Rule string + Msg string +} + +func (e *SelError) Error() string { return e.Msg } + +// maxElementNumber is the largest element number a selection may name (5.7): +// the 15-bit array-index field. +const maxElementNumber = 32767 + +func selErr(rule, format string, args ...any) (Selection, *SelError) { + return Selection{}, &SelError{Rule: rule, Msg: fmt.Sprintf(format, args...)} +} + +// selIndex is the element part of a selection. +type selIndex struct { + present bool + isRange bool + lo, hi int // saturated at exprNumSaturate + loDigits, hiDigits string // leading zeros stripped +} + +// splitSelection splits a trimmed selection into the part before the +// element part and the element part. ok is false when the text breaks the +// 5.16 grammar of the element part: a ']' before any '[', an element part that +// is not "[num]" or "[num-num]" (num = one or more decimal digits), or any +// character after the closing ']'. +func splitSelection(s string) (head string, idx selIndex, ok bool) { + open := strings.IndexAny(s, "[]") + if open < 0 { + return s, idx, true + } + if s[open] == ']' { + return "", idx, false + } + head = s[:open] + idx.present = true + var good bool + pos := open + 1 + idx.lo, idx.loDigits, pos, good = readExprNum(s, pos) + if !good { + return "", idx, false + } + idx.hi, idx.hiDigits = idx.lo, idx.loDigits + if pos < len(s) && s[pos] == '-' { + idx.hi, idx.hiDigits, pos, good = readExprNum(s, pos+1) + if !good { + return "", idx, false + } + idx.isRange = true + } + if pos != len(s)-1 || s[pos] != ']' { + return "", idx, false + } + return head, idx, true +} + +// checkIndex applies 5.7 and then 5.5 to an element part and fills in the +// selection's form and numbers. +func checkIndex(sel Selection, idx selIndex) (Selection, *SelError) { + if !idx.present { + sel.Form = SelBare + return sel, nil + } + // 5.7 (a saturated number is always above the bound) + if idx.lo > maxElementNumber || idx.hi > maxElementNumber { + return selErr("5.7", + "invalid element selection '%s': element numbers must be at most %d", + sel.Raw, maxElementNumber) + } + // 5.5 (compared on the digit strings, so the order is exact) + if digitsLess(idx.hiDigits, idx.loDigits) { + return selErr("5.5", + "invalid element selection '%s': the range is reversed (last element number is less than the first)", + sel.Raw) + } + sel.Lo, sel.Hi = idx.lo, idx.hi + sel.Form = SelSingle + if idx.isRange { + sel.Form = SelRange + } + return sel, nil +} + +func isDecimal(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// ParseIdentitySelection parses a job identity, optionally followed by "[k]" +// or "[a-b]". The identity is the written form c-a-ts-seq (strict field +// widths) or a decimal uint64 (base 10). Ranges are always allowed at the +// server. Whitespace before the identity or after the closing ']' is ignored; +// whitespace anywhere else is invalid. Checks run in the order of 5.12 -- +// 5.16, 5.14, 5.17, 5.8, 5.7, 5.5 -- and only the first failure is reported. +// Numbers may have leading zeros (1.5). +func ParseIdentitySelection(raw string) (Selection, *SelError) { + sel := Selection{Raw: strings.TrimSpace(raw)} + + // 5.16: grammar of the element part and of the identity itself. + head, idx, ok := splitSelection(sel.Raw) + if !ok { + return selErr("5.16", + "invalid job identity or element selection '%s': expected , [k] or [a-b]", + sel.Raw) + } + decimal := isDecimal(head) + var key uint64 + var err error + if decimal { + key, err = strconv.ParseUint(head, 10, 64) + if err != nil { + // all digits, so the only failure is a value above uint64 (5.14) + return selErr("5.14", "invalid job identity '%s': the job number is too large", sel.Raw) + } + } else { + key, err = backend.ParseHumanJobIDStrict(head) + if errors.Is(err, backend.ErrJobIDWidth) { + // 5.14 + return selErr("5.14", + "invalid job identity '%s': a field is wider than allowed (cluster <= 63, API server <= 15, timestamp < 2^32, sequence < 2^20)", + sel.Raw) + } + if err != nil { + return selErr("5.16", + "invalid job identity or element selection '%s': expected , [k] or [a-b]", + sel.Raw) + } + } + sel.Key = key + + // 5.17: a decimal key carrying the array marking, without [k]. The + // written form never carries the marking. + if decimal && !idx.present && backend.IsArrayJob(key) { + return selErr("5.17", + "invalid job identity '%s': array elements must be named as [k]", sel.Raw) + } + // 5.8: only with an element part. + if idx.present && !backend.IsArrayCapableKey(key) { + return selErr("5.8", + "invalid job identity '%s': '%s' is not an array identity", sel.Raw, head) + } + return checkIndex(sel, idx) +} + +// ParseNameSelection parses a job-name selection "name[k]" or "name[a-b]". +// Only 5.16, 5.7 and 5.5 apply (in that order). The name part is zero or more +// characters, none of them whitespace, '[' or ']'; it is not checked against +// the submission array-name rule (a name no array can have selects no job). +// Whitespace before the name or after the closing ']' is ignored. +// +// A value with no '[' or ']' is not an element selection: it is returned as a +// SelBare selection whose Name is the value exactly as given, so a caller +// converting a whole filter entry keeps today's plain-name match for it. +func ParseNameSelection(raw string) (Selection, *SelError) { + sel := Selection{Raw: strings.TrimSpace(raw)} + if !strings.ContainsAny(raw, "[]") { + sel.Name = raw + sel.Form = SelBare + return sel, nil + } + + // 5.16 + head, idx, ok := splitSelection(sel.Raw) + if ok && strings.IndexFunc(head, unicode.IsSpace) >= 0 { + ok = false + } + if !ok { + return selErr("5.16", + "invalid element selection '%s': expected name[k] or name[a-b] (no whitespace in the name or brackets)", + sel.Raw) + } + sel.Name = head + return checkIndex(sel, idx) +} diff --git a/src/frontend/apiserver/controllers/jobsel_test.go b/src/frontend/apiserver/controllers/jobsel_test.go new file mode 100644 index 00000000..aedeb1d6 --- /dev/null +++ b/src/frontend/apiserver/controllers/jobsel_test.go @@ -0,0 +1,335 @@ +package controllers + +import ( + "strconv" + "strings" + "testing" + + "apiserver/backend" +) + +// Shared conformance table `selection-cases` (design.md, Testing Strategy), +// run for the REST surface: identity rows (written c-a-ts-seq and decimal +// keys) through ParseIdentitySelection, name rows through ParseNameSelection. +// `X` stands for a valid, array-capable written identity (sequence a multiple +// of 32768); `c-a-ts-5` for one that no array could have. + +const ( + selX = "3-5-1700000000-65536" + selXBad = "3-5-1700000000-5" // sequence not a multiple of 32768 (5.8) +) + +// selXKey is the packed, untagged value of selX. +func selXKey(t *testing.T) uint64 { + t.Helper() + k, err := backend.ParseHumanJobIDStrict(selX) + if err != nil { + t.Fatalf("ParseHumanJobIDStrict(%q): %v", selX, err) + } + return k +} + +type selWant struct { + rule string // "" = accepted; else "5.16", "5.14", "5.17", "5.8", "5.7", "5.5" + form SelForm + lo int + hi int + exact bool // check key below instead of selXKey + key uint64 // expected Key when exact +} + +func accepted(form SelForm, lo, hi int) selWant { return selWant{form: form, lo: lo, hi: hi} } +func rejected(rule string) selWant { return selWant{rule: rule} } + +type selCase struct { + in string + want selWant +} + +// identitySelectionCases are the identity rows of the shared table, copied +// from design.md, plus REST-only identity rows. +func identitySelectionCases() []selCase { + return []selCase{ + // --- Valid --- + {selX, accepted(SelBare, 0, 0)}, + {selX + "[1]", accepted(SelSingle, 1, 1)}, + {selX + "[0]", accepted(SelSingle, 0, 0)}, + {selX + "[01]", accepted(SelSingle, 1, 1)}, + {selX + "[1-3]", accepted(SelRange, 1, 3)}, + {selX + "[3-3]", accepted(SelRange, 3, 3)}, + {selX + "[32767]", accepted(SelSingle, 32767, 32767)}, + // leading zeros (1.5) + {selX + "[001-0003]", accepted(SelRange, 1, 3)}, + {"03-05-01700000000-065536[2]", accepted(SelSingle, 2, 2)}, + // whitespace before the identity / after the closing ']' is ignored (5.16) + {" \t" + selX + "[2] \t", accepted(SelSingle, 2, 2)}, + {" " + selX + " ", accepted(SelBare, 0, 0)}, + // a bare ordinary identity needs no alignment (5.6) + {selXBad, selWant{form: SelBare, exact: true, key: 3<<56 | 5<<52 | 1700000000<<20 | 5}}, + + // --- Syntax (5.16) --- + {selX + " [1]", rejected("5.16")}, + {selX + "[a]", rejected("5.16")}, + {selX + "[]", rejected("5.16")}, + {selX + "[1", rejected("5.16")}, + {selX + "]", rejected("5.16")}, + {selX + "[+1]", rejected("5.16")}, + {selX + "[-1]", rejected("5.16")}, + {selX + "[ 1]", rejected("5.16")}, + {selX + "[1-3:2]", rejected("5.16")}, + {selX + "[1]x", rejected("5.16")}, + {selX + "[1-2-3]", rejected("5.16")}, + {selX + "[1 ]", rejected("5.16")}, + {selX + "[1,3]", rejected("5.16")}, + {selX + "[1][2]", rejected("5.16")}, + {selX + "[1-]", rejected("5.16")}, + {selX + "[-3]", rejected("5.16")}, + {selX + "x", rejected("5.16")}, + {"", rejected("5.16")}, + {" \t ", rejected("5.16")}, + {"[1]", rejected("5.16")}, + {"3-5-1700000000", rejected("5.16")}, + {"3-5-1700000000-65536-1", rejected("5.16")}, + {"+3-5-1700000000-65536", rejected("5.16")}, + {"3 -5-1700000000-65536", rejected("5.16")}, + {"foo[1]", rejected("5.16")}, + {"99999999999999999999-0-0-0[1]x", rejected("5.16")}, + + // --- Widths (5.14) --- + {"64-0-1700000000-32768[1]", rejected("5.14")}, + {"0-16-1700000000-32768[1]", rejected("5.14")}, + {"0-0-4294967296-32768[1]", rejected("5.14")}, + {"0-0-1700000000-1048576[1]", rejected("5.14")}, + {"64-0-1700000000-32768", rejected("5.14")}, + {"0-0-1700000000-1048576", rejected("5.14")}, + {"18446744073709551616-0-0-0", rejected("5.14")}, + + // --- Alignment (5.8) --- + {selXBad + "[3]", rejected("5.8")}, + {"3-5-1700000000-65537[1-2]", rejected("5.8")}, + + // --- Index (5.7) --- + {selX + "[32768]", rejected("5.7")}, + {selX + "[1-40000]", rejected("5.7")}, + {selX + "[40000-1]", rejected("5.7")}, + {selX + "[99999999999999999999]", rejected("5.7")}, + {selX + "[18446744073709551617]", rejected("5.7")}, + + // --- Reversed (5.5) --- + {selX + "[3-1]", rejected("5.5")}, + {selX + "[32767-0]", rejected("5.5")}, + + // --- Precedence (5.12) --- + {"0-0-1700000000-1048576[40000]", rejected("5.14")}, + {selXBad + "[3-1]", rejected("5.8")}, + {selXBad + "[40000]", rejected("5.8")}, + {"64-0-1700000000-5[3-1]", rejected("5.14")}, + {"64-0-1700000000-5 [3-1]", rejected("5.16")}, + } +} + +func checkSel(t *testing.T, fn string, in string, got Selection, gotErr *SelError, want selWant, wantKey uint64) { + t.Helper() + if want.rule != "" { + if gotErr == nil { + t.Fatalf("%s(%q) accepted (%+v), want rule %s", fn, in, got, want.rule) + } + if gotErr.Rule != want.rule { + t.Fatalf("%s(%q) rule = %s (%q), want %s", fn, in, gotErr.Rule, gotErr.Msg, want.rule) + } + // messages name the selection as written (trimmed) + if trimmed := strings.TrimSpace(in); !strings.Contains(gotErr.Msg, "'"+trimmed+"'") { + t.Fatalf("%s(%q) message %q does not quote %q", fn, in, gotErr.Msg, trimmed) + } + return + } + if gotErr != nil { + t.Fatalf("%s(%q) rejected: rule %s %q", fn, in, gotErr.Rule, gotErr.Msg) + } + if got.Form != want.form || got.Lo != want.lo || got.Hi != want.hi { + t.Fatalf("%s(%q) = form %d [%d-%d], want form %d [%d-%d]", + fn, in, got.Form, got.Lo, got.Hi, want.form, want.lo, want.hi) + } + if got.Key != wantKey { + t.Fatalf("%s(%q) key = %d, want %d", fn, in, got.Key, wantKey) + } + if got.Raw != strings.TrimSpace(in) { + t.Fatalf("%s(%q) Raw = %q, want %q", fn, in, got.Raw, strings.TrimSpace(in)) + } +} + +func TestParseIdentitySelection_SelectionCases(t *testing.T) { + xKey := selXKey(t) + for _, c := range identitySelectionCases() { + t.Run(c.in, func(t *testing.T) { + got, err := ParseIdentitySelection(c.in) + wantKey := xKey + if c.want.exact { + wantKey = c.want.key + } + checkSel(t, "ParseIdentitySelection", c.in, got, err, c.want, wantKey) + }) + } +} + +// Decimal keys (REST only): the same rules apply to a base-10 uint64 key, +// plus 5.17 for a decimal key carrying the array marking without [k]. +func TestParseIdentitySelection_DecimalKeys(t *testing.T) { + xKey := selXKey(t) + xDec := strconv.FormatUint(xKey, 10) + elem2 := backend.MakeElementID(xKey, 2) + elem2Dec := strconv.FormatUint(elem2, 10) + ordinary := xKey | 5 // not array-capable + + cases := []struct { + in string + want selWant + wantKey uint64 + }{ + {xDec, accepted(SelBare, 0, 0), xKey}, + {xDec + "[2]", accepted(SelSingle, 2, 2), xKey}, + {"0" + xDec + "[1-3]", accepted(SelRange, 1, 3), xKey}, + {" " + xDec + " ", accepted(SelBare, 0, 0), xKey}, + {"123456", accepted(SelBare, 0, 0), 123456}, + {"0", accepted(SelBare, 0, 0), 0}, + // 5.17: marked decimal without [k], whether or not that element exists + {elem2Dec, rejected("5.17"), 0}, + {" " + elem2Dec + " ", rejected("5.17"), 0}, + // marked decimal with [k] is not an array identity (5.8) + {elem2Dec + "[1]", rejected("5.8"), 0}, + // 5.8 for decimal keys with [...] + {strconv.FormatUint(ordinary, 10) + "[3]", rejected("5.8"), 0}, + {"123456[3]", rejected("5.8"), 0}, + // 5.7 / 5.5 for decimal keys + {xDec + "[40000]", rejected("5.7"), 0}, + {xDec + "[3-1]", rejected("5.5"), 0}, + // not decimal and not written form (5.16) + {"1e3", rejected("5.16"), 0}, + {"-1", rejected("5.16"), 0}, + {"+1", rejected("5.16"), 0}, + {"0x10", rejected("5.16"), 0}, + {"12 34", rejected("5.16"), 0}, + {xDec + " [1]", rejected("5.16"), 0}, + {xDec + "[1]x", rejected("5.16"), 0}, + // too large for uint64 (5.14), checked before 5.17/5.8/5.7 + {"18446744073709551616", rejected("5.14"), 0}, + {"99999999999999999999999[40000]", rejected("5.14"), 0}, + // precedence: syntax before width + {"99999999999999999999999[1]x", rejected("5.16"), 0}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := ParseIdentitySelection(c.in) + checkSel(t, "ParseIdentitySelection", c.in, got, err, c.want, c.wantKey) + }) + } +} + +func TestParseIdentitySelection_517Message(t *testing.T) { + elem := strconv.FormatUint(backend.MakeElementID(selXKey(t), 2), 10) + _, err := ParseIdentitySelection(elem) + if err == nil || !strings.Contains(err.Msg, "array elements must be named as [k]") { + t.Fatalf("5.17 message = %+v", err) + } +} + +func TestParseIdentitySelection_RuleMessages(t *testing.T) { + cases := []struct{ in, has string }{ + {selX + "[3-1]", "reversed"}, + {selX + "[40000]", "32767"}, + {selXBad + "[3]", "invalid job identity"}, + {"64-0-1700000000-32768", "invalid job identity"}, + {selX + "[a]", "invalid"}, + } + for _, c := range cases { + _, err := ParseIdentitySelection(c.in) + if err == nil || !strings.Contains(err.Msg, c.has) { + t.Fatalf("ParseIdentitySelection(%q) = %+v, want message containing %q", c.in, err, c.has) + } + } +} + +// Name rows of the shared table plus 5.7 / 5.5 for names. Names get only +// 5.16, 5.7 and 5.5; the name part is zero or more characters, none of them +// whitespace, '[' or ']', and is not checked against the array-name rule. +func TestParseNameSelection_SelectionCases(t *testing.T) { + type nw struct { + rule string + name string + form SelForm + lo, hi int + } + cases := []struct { + in string + want nw + }{ + // accepted + {"foo[1]", nw{name: "foo", form: SelSingle, lo: 1, hi: 1}}, + {"[1]", nw{name: "", form: SelSingle, lo: 1, hi: 1}}, + {"foo[1-3]", nw{name: "foo", form: SelRange, lo: 1, hi: 3}}, + {"foo[3-3]", nw{name: "foo", form: SelRange, lo: 3, hi: 3}}, + {"foo[0]", nw{name: "foo", form: SelSingle, lo: 0, hi: 0}}, + {"foo[007]", nw{name: "foo", form: SelSingle, lo: 7, hi: 7}}, + {"foo[32767]", nw{name: "foo", form: SelSingle, lo: 32767, hi: 32767}}, + {" \tfoo[2]\t ", nw{name: "foo", form: SelSingle, lo: 2, hi: 2}}, + {"a*b/c[1]", nw{name: "a*b/c", form: SelSingle, lo: 1, hi: 1}}, // no array-name rule + // no brackets: a plain name, kept as written (today's exact-match value) + {"foo", nw{name: "foo", form: SelBare}}, + {"sleep 100", nw{name: "sleep 100", form: SelBare}}, + // rejected: syntax (5.16) + {"foo [1]", nw{rule: "5.16"}}, + {"my job[1]", nw{rule: "5.16"}}, + {"foo[1]x", nw{rule: "5.16"}}, + {"foo[ 1-3]", nw{rule: "5.16"}}, + {"foo[a]", nw{rule: "5.16"}}, + {"foo[1-3]x", nw{rule: "5.16"}}, + {"foo[]", nw{rule: "5.16"}}, + {"foo[1", nw{rule: "5.16"}}, + {"foo]", nw{rule: "5.16"}}, + {"foo]1[", nw{rule: "5.16"}}, + {"foo[+1]", nw{rule: "5.16"}}, + {"foo[-1]", nw{rule: "5.16"}}, + {"foo[1-3:2]", nw{rule: "5.16"}}, + {"foo[1,3]", nw{rule: "5.16"}}, + {"foo[1][2]", nw{rule: "5.16"}}, + {"foo[1-2-3]", nw{rule: "5.16"}}, + // 5.7 + {"foo[32768]", nw{rule: "5.7"}}, + {"foo[1-40000]", nw{rule: "5.7"}}, + {"foo[40000-1]", nw{rule: "5.7"}}, + {"foo[99999999999999999999]", nw{rule: "5.7"}}, + // 5.5 (compared exactly, leading zeros stripped) + {"foo[3-1]", nw{rule: "5.5"}}, + {"foo[0010-009]", nw{rule: "5.5"}}, + // precedence: syntax before 5.7 and 5.5 + {"foo [40000]", nw{rule: "5.16"}}, + {"foo[3-1]x", nw{rule: "5.16"}}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := ParseNameSelection(c.in) + trimmed := strings.TrimSpace(c.in) + if c.want.rule != "" { + if err == nil { + t.Fatalf("ParseNameSelection(%q) accepted (%+v), want rule %s", c.in, got, c.want.rule) + } + if err.Rule != c.want.rule { + t.Fatalf("ParseNameSelection(%q) rule = %s (%q), want %s", c.in, err.Rule, err.Msg, c.want.rule) + } + if !strings.Contains(err.Msg, "'"+trimmed+"'") { + t.Fatalf("ParseNameSelection(%q) message %q does not quote %q", c.in, err.Msg, trimmed) + } + return + } + if err != nil { + t.Fatalf("ParseNameSelection(%q) rejected: %s %q", c.in, err.Rule, err.Msg) + } + if got.Name != c.want.name || got.Form != c.want.form || got.Lo != c.want.lo || got.Hi != c.want.hi { + t.Fatalf("ParseNameSelection(%q) = %+v, want %+v", c.in, got, c.want) + } + if got.Raw != trimmed || got.Key != 0 { + t.Fatalf("ParseNameSelection(%q) Raw/Key = %q/%d", c.in, got.Raw, got.Key) + } + }) + } +}