From 4d7d76977398a1dda631ba7c2b9ff4554476b425 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 3 Sep 2026 18:08:52 +0200 Subject: [PATCH] test(mangling/ucd): added tests to cover UCD-based generation commands Bugs fixed: * generation was referencing a deprecated location * numeral interpretation of NaN/Inf is handled by the mangler, not in the tables, where it yields an incorrect result * the generators recorded the UCD source path as filepath.Rel returned it, so a regen on Windows rewrote "Generated from v15/DerivedName.txt" with a backslash and churned every table; filepath.ToSlash pins it Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .golangci.yml | 4 + mangling/go_ident_fallback_test.go | 24 ++ mangling/numbers/cardinal.go | 14 +- mangling/numbers/coverage_test.go | 52 +++ mangling/numbers/fraction.go | 54 ++- .../ucd/cmd/gen_asciifold/gen_asciifold.go | 3 + .../cmd/gen_asciifold/gen_asciifold_test.go | 250 ++++++++++++ mangling/ucd/cmd/gen_numerals/gen_numerals.go | 20 +- .../ucd/cmd/gen_numerals/gen_numerals_test.go | 221 ++++++++++ .../ucd/cmd/gen_runewords/gen_runewords.go | 3 + .../cmd/gen_runewords/gen_runewords_test.go | 379 ++++++++++++++++++ mangling/ucd/go.mod | 2 + mangling/ucd/go.sum | 2 + mangling/ucd/internal/locate/root.go | 2 +- mangling/ucd/internal/locate/root_test.go | 155 +++++++ 15 files changed, 1176 insertions(+), 9 deletions(-) create mode 100644 mangling/ucd/cmd/gen_asciifold/gen_asciifold_test.go create mode 100644 mangling/ucd/cmd/gen_numerals/gen_numerals_test.go create mode 100644 mangling/ucd/cmd/gen_runewords/gen_runewords_test.go create mode 100644 mangling/ucd/go.sum create mode 100644 mangling/ucd/internal/locate/root_test.go diff --git a/.golangci.yml b/.golangci.yml index 1886180..326bc79 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -62,6 +62,10 @@ linters: - third_party$ - builtin$ - examples$ + rules: + - path: mangling/ucd + linters: + - mnd formatters: enable: - gofmt diff --git a/mangling/go_ident_fallback_test.go b/mangling/go_ident_fallback_test.go index 3d4bfc0..69aaf93 100644 --- a/mangling/go_ident_fallback_test.go +++ b/mangling/go_ident_fallback_test.go @@ -4,7 +4,10 @@ package mangling import ( + "strings" "testing" + "unicode" + "unicode/utf8" "github.com/go-openapi/testify/v2/assert" ) @@ -80,3 +83,24 @@ func TestGoIdentFallback(t *testing.T) { assert.EqualT(t, "", m.Pascalize("日本")) // elided CJK runes }) } + +// TestGoIdentExtremeNumbers covers the sibling contract: whatever the magnitude of a number in the input, a Go +// identifier producer verbalizes it and never leaves a leading digit behind. +// +// The numbers engine spells an integer too large for int64 digit by digit, and -2^63 the same way. +func TestGoIdentExtremeNumbers(t *testing.T) { + t.Parallel() + + g := MakeGoMangler() + for _, in := range []string{ + "99999999999999999999.5", // integer part beyond int64 + "-9223372036854775808 items", // -2^63, no positive int64 counterpart + "1" + strings.Repeat("0", 400), // beyond float64 range: ParseFloat overflows to +Inf + "1" + strings.Repeat("0", 400) + ".5", // same, with a fractional part + } { + got := g.IdentExported(in) + assert.NotEmptyf(t, got, "IdentExported(%q)", in) + first, _ := utf8.DecodeRuneInString(got) + assert.Truef(t, unicode.IsLetter(first), "IdentExported(%q) starts with %q: %.40q", in, first, got) + } +} diff --git a/mangling/numbers/cardinal.go b/mangling/numbers/cardinal.go index 51b324b..7ac274c 100644 --- a/mangling/numbers/cardinal.go +++ b/mangling/numbers/cardinal.go @@ -3,7 +3,10 @@ package numbers -import "strconv" +import ( + "math" + "strconv" +) // maxFullCardinal is the default magnitude up to which a number is spelled out in full. // @@ -78,6 +81,15 @@ func writeCardinal(b *buf, n int64, o numberOptions) { if n < 0 { _, _ = b.WriteString("minus ") + + if n == math.MinInt64 { + // -2^63 has no positive int64 counterpart, so negating it would leave n negative and spell nothing at all. + // Spell its digits instead, as [writeSpellDecimal] does for an integer too large for int64. + writeDigitWords(b, "9223372036854775808") + + return + } + n = -n } diff --git a/mangling/numbers/coverage_test.go b/mangling/numbers/coverage_test.go index a66c2a9..198c962 100644 --- a/mangling/numbers/coverage_test.go +++ b/mangling/numbers/coverage_test.go @@ -4,6 +4,7 @@ package numbers import ( + "math" "strings" "testing" @@ -25,6 +26,57 @@ func TestNumberWordsOverflow(t *testing.T) { assert.EqualT(t, "minus one"+strings.Repeat(" zero", 19), neg) } +// TestNonFiniteValues covers the wording of NaN and the two infinities, which have no cardinal form. +// +// The generated numeral table holds finite values only (see the gen_numerals command), so this guards every other way +// a value reaches the verbalizer. +func TestNonFiniteValues(t *testing.T) { + t.Parallel() + + var o numberOptions + + assert.EqualT(t, "not a number", numberWords(math.NaN(), o)) + assert.EqualT(t, "infinity", numberWords(math.Inf(1), o)) + assert.EqualT(t, "minus infinity", numberWords(math.Inf(-1), o)) + + // The streaming form agrees with the string form. + for _, x := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + var b buf + writeNumberValue(&b, x, o) + assert.EqualTf(t, numberWords(x, o), string(b.b), "writeNumberValue(%v)", x) + } +} + +// TestNumberWordsDecimalOverflow covers a decimal whose integer part is too large for int64. +// +// strconv.ParseInt clamps such an integer to int64 max, so the digits are spelled one by one instead — as +// [TestNumberWordsOverflow] does for a plain integer. +func TestNumberWordsDecimalOverflow(t *testing.T) { + t.Parallel() + + m := MakeNumberMangler() + + got := m.NumberWords("99999999999999999999.5") // 20 nines, > int64 max + assert.EqualT(t, strings.Repeat("nine ", 20)+"dot five", got) + + // Beyond float64 range: strconv.ParseFloat returns +Inf with strconv.ErrRange. + huge := m.NumberWords("1" + strings.Repeat("0", 400) + ".5") + assert.Falsef(t, strings.ContainsAny(huge, "0123456789"), "overflowing decimal left raw digits: %q", huge) + assert.Truef(t, strings.HasSuffix(huge, " dot five"), "overflowing decimal lost its fractional part: %q", huge) +} + +// TestCardinalMinInt64 covers -2^63, the one negative int64 with no positive counterpart: negating it leaves it +// negative, so writeCardinal spells its digits instead of emitting "minus " and nothing else. +func TestCardinalMinInt64(t *testing.T) { + t.Parallel() + + //nolint:dupword // the repeated words are the digit-by-digit spelling of 9223372036854775808 + const want = "minus nine two two three three seven two zero three six eight five four seven seven five eight zero eight" + + assert.EqualT(t, want, cardinal(math.MinInt64, numberOptions{})) + assert.EqualT(t, want, MakeNumberMangler().NumberWords("-9223372036854775808")) +} + // TestWithNumberDetectPrecision covers the tolerance knob: a tighter precision stops a loose decimal from matching a // simple fraction. func TestWithNumberDetectPrecision(t *testing.T) { diff --git a/mangling/numbers/fraction.go b/mangling/numbers/fraction.go index 3d97206..79ccb5d 100644 --- a/mangling/numbers/fraction.go +++ b/mangling/numbers/fraction.go @@ -4,6 +4,7 @@ package numbers import ( + "errors" "math" "strconv" "strings" @@ -132,12 +133,12 @@ func writeSpellDecimal(b *buf, s string, o numberOptions) { } x, err := strconv.ParseFloat(s, 64) - if err != nil { - _, _ = b.WriteString(s) + if errors.Is(err, strconv.ErrSyntax) { + _, _ = b.WriteString(s) // not a number at all: copy it through untouched return } - if x > -1 && x < 1 && x != 0 { + if err == nil && x > -1 && x < 1 && x != 0 { if writeFraction(b, x, o) { return } @@ -154,16 +155,51 @@ func writeSpellDecimal(b *buf, s string, o numberOptions) { if intPart == "" { intPart = "0" } - intVal, _ := strconv.ParseInt(intPart, 10, 64) - - writeCardinal(b, intVal, o) + if intVal, err := strconv.ParseInt(intPart, 10, 64); err == nil { + writeCardinal(b, intVal, o) + } else { + // The integer part overflows int64, so ParseFloat has returned +Inf or -Inf with strconv.ErrRange. Spell the + // digits one by one, as the integer branch above does, so the result never starts with a raw digit. + writeDigitWords(b, intPart) + } _, _ = b.WriteString(" dot ") writeDigitWords(b, fracPart) } +// nonFiniteWords are the words for the three float64 values no cardinal covers. +const ( + nanWords = "not a number" + infWords = "infinity" + negativeInfWords = "minus infinity" +) + +// nonFinite returns the wording of NaN and the two infinities, and whether x is one of them. +// +// The generated numeral table holds finite values only, so no numeral rune reaches this. It guards the conversion in +// [numberWords] and [writeNumberValue]: int64(+Inf) is platform-defined (-2^63 on amd64), which spells out as "minus" +// and nothing else. +func nonFinite(x float64) (string, bool) { + switch { + case math.IsNaN(x): + return nanWords, true + case math.IsInf(x, 1): + return infWords, true + case math.IsInf(x, -1): + return negativeInfWords, true + default: + return "", false + } +} + // numberWords renders a numeric value as words: cardinal for integral values, fraction/decimal otherwise (the value is // formatted to its shortest exact decimal string first). +// +// NaN and the infinities have no cardinal form and render as [nanWords], [infWords] and [negativeInfWords]. func numberWords(x float64, o numberOptions) string { + if w, ok := nonFinite(x); ok { + return w + } + if x == math.Trunc(x) { return cardinal(int64(x), o) } @@ -174,6 +210,12 @@ func numberWords(x float64, o numberOptions) string { // writeNumberValue streams the english wording of a numeric value into b — the streaming form of [numberWords], used // to verbalize a Unicode numeral rune ('½' → "one half", 'Ⅶ' → "seven"). func writeNumberValue(b *buf, x float64, o numberOptions) { + if w, ok := nonFinite(x); ok { + _, _ = b.WriteString(w) + + return + } + if x == math.Trunc(x) { writeCardinal(b, int64(x), o) diff --git a/mangling/ucd/cmd/gen_asciifold/gen_asciifold.go b/mangling/ucd/cmd/gen_asciifold/gen_asciifold.go index c2c92f2..9fb9220 100644 --- a/mangling/ucd/cmd/gen_asciifold/gen_asciifold.go +++ b/mangling/ucd/cmd/gen_asciifold/gen_asciifold.go @@ -150,6 +150,9 @@ func run(pkg, outFile, ucdDir, buildTag string) error { if err != nil { return err } + // The generated file records this path, so keep it slash-separated: regenerating on Windows must not rewrite + // "v15/DerivedName.txt" as "v15\DerivedName.txt" and churn every table. + inFile = filepath.ToSlash(inFile) if err := emit(inFile, pkg, outFile, buildTag, out); err != nil { return err diff --git a/mangling/ucd/cmd/gen_asciifold/gen_asciifold_test.go b/mangling/ucd/cmd/gen_asciifold/gen_asciifold_test.go new file mode 100644 index 0000000..52bb330 --- /dev/null +++ b/mangling/ucd/cmd/gen_asciifold/gen_asciifold_test.go @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/go-openapi/codegen/mangling/ucd/internal/locate" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// derivedNameFixture is a hand-picked slice of DerivedName.txt: one line per rule deriveFold applies, plus lines the +// generator must skip. +const derivedNameFixture = `# DerivedName-15.0.0.txt +# a comment line + +0041 ; LATIN CAPITAL LETTER A +00E9 ; LATIN SMALL LETTER E WITH ACUTE +00C9 ; LATIN CAPITAL LETTER E WITH ACUTE +00E6 ; LATIN SMALL LETTER AE +01FC ; LATIN CAPITAL LETTER AE WITH ACUTE +01C5 ; LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON +0254 ; LATIN SMALL LETTER OPEN O +0294 ; LATIN LETTER GLOTTAL STOP +0416 ; CYRILLIC CAPITAL LETTER ZHE +FB00 ; LATIN SMALL LIGATURE FF +3400..4DBF ; CJK UNIFIED IDEOGRAPH-* +` + +func TestDeriveFold(t *testing.T) { + t.Parallel() + + folded := map[string]string{ + "LATIN SMALL LETTER E WITH ACUTE": "e", + "LATIN CAPITAL LETTER E WITH ACUTE": "E", + "LATIN SMALL LETTER O WITH HORN": "o", + "LATIN SMALL LETTER AE": "ae", + "LATIN CAPITAL LETTER AE WITH ACUTE": "AE", + "LATIN SMALL LETTER DZ": "dz", + "LATIN SMALL LIGATURE FF": "ff", + "LATIN CAPITAL LIGATURE OE": "OE", + "LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON": "D", + } + for name, want := range folded { + got, ok := deriveFold(name) + assert.Truef(t, ok, "deriveFold(%q) should fold", name) + assert.Equalf(t, want, got, "deriveFold(%q)", name) + } + + // Distinct letters with no ASCII base, and non-Latin names. + for _, name := range []string{ + "LATIN SMALL LETTER OPEN O", + "LATIN SMALL LETTER SCHWA", + "LATIN SMALL LETTER ESH", + "LATIN LETTER GLOTTAL STOP", // uncased: IPA/phonetic, not a cased letter + "CYRILLIC CAPITAL LETTER ZHE", + "GREEK SMALL LETTER ALPHA", + "SNOWMAN", + } { + got, ok := deriveFold(name) + assert.Falsef(t, ok, "deriveFold(%q) should not fold", name) + assert.Emptyf(t, got, "deriveFold(%q)", name) + } +} + +// TestGenerateAsciifold runs the generator over the fixture and reads the emitted table back. +func TestGenerateAsciifold(t *testing.T) { + t.Parallel() + + out := generateAsciifold(t, derivedNameFixture, "!go1.27") + src, err := os.ReadFile(out) + require.NoError(t, err) + + file, err := parser.ParseFile(token.NewFileSet(), out, src, parser.ParseComments) + require.NoError(t, err, "the generated table must be valid Go") + assert.Equal(t, "mangling", file.Name.Name) + assert.Contains(t, string(src), "//go:build !go1.27") + assert.Contains(t, string(src), "DO NOT EDIT") + assert.Contains(t, string(src), "Generated from v15/DerivedName.txt", "the source path is recorded relative to the ucd root") + + folds := asciiFoldTable(t, file) + + t.Run("derives a fold per name rule", func(t *testing.T) { + for r, want := range map[rune]string{ + 'é': "e", + 'É': "E", + 'æ': "ae", + 'Ǽ': "AE", + 'ff': "ff", + } { + assert.Equalf(t, want, folds[r], "asciiFold[%q]", r) + } + }) + + t.Run("skips runes with no ASCII base", func(t *testing.T) { + for _, r := range []rune{ + 'A', // ASCII, handled directly + 'ɔ', // OPEN O: a distinct letter + 'ʔ', // uncased LATIN LETTER + 'Ж', // not Latin + } { + _, ok := folds[r] + assert.Falsef(t, ok, "asciiFold should not hold %q", r) + } + }) + + t.Run("a seed overrides the derived fold", func(t *testing.T) { + // U+01C5 derives to "D" from its name (see TestDeriveFold), but the seeds table renders the titlecase + // digraph as "Dz". + assert.Equal(t, "Dz", folds['Dž']) + }) + + t.Run("emits every seed", func(t *testing.T) { + for r, want := range seeds { + assert.Equalf(t, want, folds[r], "seed %q missing from the table", r) + } + }) +} + +// TestGenerateAsciifoldWithoutBuildTag covers the lone-version case: no //go:build line is emitted. +func TestGenerateAsciifoldWithoutBuildTag(t *testing.T) { + t.Parallel() + + src, err := os.ReadFile(generateAsciifold(t, derivedNameFixture, "")) + require.NoError(t, err) + assert.NotContains(t, string(src), "//go:build") +} + +func TestGenerateAsciifoldMissingSource(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := run("mangling", filepath.Join(dir, "out.go"), filepath.Join(dir, "v15"), "") + require.Error(t, err, "a missing extract must be reported, not silently produce an empty table") +} + +// generateAsciifold writes the fixture as a versioned UCD directory, runs the generator over it and returns the path +// of the emitted table. +func generateAsciifold(t *testing.T, fixture, buildTag string) string { + t.Helper() + + dir := t.TempDir() + ucdDir := filepath.Join(dir, "v15") + require.NoError(t, os.MkdirAll(ucdDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(ucdDir, ucdFile), []byte(fixture), 0o600)) + + out := filepath.Join(dir, "asciifold_table15.0.0.go") + require.NoError(t, run("mangling", out, ucdDir, buildTag)) + + return out +} + +// asciiFoldTable reads the generated `var asciiFold = map[rune]string{…}` back out of the emitted source. +func asciiFoldTable(t *testing.T, file *ast.File) map[rune]string { + t.Helper() + + out := map[rune]string{} + for _, kv := range mapEntries(t, file, "asciiFold") { + key, ok := kv.Key.(*ast.BasicLit) + require.True(t, ok, "map key is not a literal") + r, err := strconv.ParseInt(key.Value, 0, 32) + require.NoErrorf(t, err, "map key %q", key.Value) + + val, ok := kv.Value.(*ast.BasicLit) + require.True(t, ok, "map value is not a literal") + s, err := strconv.Unquote(val.Value) + require.NoErrorf(t, err, "map value %q", val.Value) + + out[rune(r)] = s + } + require.NotEmpty(t, out, "no %s table in the generated source", "asciiFold") + + return out +} + +// mapEntries returns the key/value elements of the map literal assigned to the package-level variable varName. +func mapEntries(t *testing.T, file *ast.File, varName string) []*ast.KeyValueExpr { + t.Helper() + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || value.Names[0].Name != varName || len(value.Values) != 1 { + continue + } + lit, ok := value.Values[0].(*ast.CompositeLit) + require.Truef(t, ok, "%s is not a composite literal", varName) + + out := make([]*ast.KeyValueExpr, 0, len(lit.Elts)) + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + require.Truef(t, ok, "%s holds a non key/value element", varName) + out = append(out, kv) + } + + return out + } + } + require.FailNowf(t, "variable not found", "no package-level var %s in the generated source", varName) + + return nil +} + +// TestResolveArgs covers the [package [outbase [version [ucd-root]]]] command line. +// +// It swaps os.Args, so it does not run in parallel. +func TestResolveArgs(t *testing.T) { + saved := os.Args + t.Cleanup(func() { os.Args = saved }) + + t.Run("defaults to the mangling package and the baseline version", func(t *testing.T) { + if _, err := locate.UCDRoot(); err != nil { + t.Skipf("no git checkout to resolve the default UCD root from: %v", err) + } + + os.Args = []string{"gen_asciifold"} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + baseline := locate.Versions[0] + assert.Equal(t, "mangling", pkg) + assert.Equal(t, "asciifold_table"+baseline.UCD+".go", outFile) + assert.Equal(t, baseline.Dir, filepath.Base(ucdDir)) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) + + t.Run("takes every argument", func(t *testing.T) { + root := t.TempDir() + baseline := locate.Versions[0] + + os.Args = []string{"gen_asciifold", "otherpkg", "othertable", baseline.UCD, root} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + assert.Equal(t, "otherpkg", pkg) + assert.Equal(t, "othertable"+baseline.UCD+".go", outFile, "the version is appended to the output base name") + assert.Equal(t, filepath.Join(root, baseline.Dir), ucdDir) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) +} diff --git a/mangling/ucd/cmd/gen_numerals/gen_numerals.go b/mangling/ucd/cmd/gen_numerals/gen_numerals.go index 6d4315e..97523e9 100644 --- a/mangling/ucd/cmd/gen_numerals/gen_numerals.go +++ b/mangling/ucd/cmd/gen_numerals/gen_numerals.go @@ -7,7 +7,9 @@ // It keeps the No (Number, other: vulgar fractions, superscripts, circled digits, ...) and Nl // (Number, letter: roman/acrophonic/cuneiform numerals) categories, and drops: // - Nd (decimal digits) — the mangler already handles those via a digit-value offset; -// - Lo (CJK ideographic numbers) — Han script, elided during asciification. +// - Lo (CJK ideographic numbers) — Han script, elided during asciification; +// - runes whose value is NaN or infinite — UCD writes NaN for "no numeric value", and Go has no literal for either +// (strconv.FormatFloat emits the undefined identifiers NaN, +Inf and -Inf). // // The emitted map[rune]float64 feeds numbers.RuneNumber, so a Unicode numeral verbalizes through the same engine as an // ASCII number (½ -> "one half"), and the asciify tier can render it as a plain number (½ -> "0.5"). @@ -29,6 +31,7 @@ import ( "fmt" "go/format" "log" + "math" "os" "path/filepath" "sort" @@ -66,6 +69,7 @@ func run(pkg, outFile, ucdDir, buildTag string) error { defer f.Close() var nums []numeral + var nonFinite int kept := map[string]int{} // category tallies, for the report sc := bufio.NewScanner(f) @@ -87,6 +91,14 @@ func run(pkg, outFile, ucdDir, buildTag string) error { if err != nil { continue } + if math.IsNaN(v) || math.IsInf(v, 0) { + // UCD writes NaN for a rune with no numeric value, and ParseFloat also accepts the literal words "nan" and + // "inf". Go has no constant for either: strconv.FormatFloat would emit NaN, +Inf or -Inf, which are + // undefined identifiers in the generated table. Drop the rune instead. + nonFinite++ + + continue + } lo, hi, err := codeRange(strings.TrimSpace(fields[0])) if err != nil { continue @@ -105,6 +117,9 @@ func run(pkg, outFile, ucdDir, buildTag string) error { if err != nil { return err } + // The generated file records this path, so keep it slash-separated: regenerating on Windows must not rewrite + // "v15/DerivedName.txt" as "v15\DerivedName.txt" and churn every table. + inFile = filepath.ToSlash(inFile) if err := emit(inFile, pkg, outFile, buildTag, nums); err != nil { return err @@ -114,6 +129,9 @@ func run(pkg, outFile, ucdDir, buildTag string) error { "numerals: kept No=%d Nl=%d, %d runes after range expansion (%d KiB map data)\n", kept["No"], kept["Nl"], len(nums), (len(nums)*(4+8))/1024, ) + if nonFinite > 0 { + fmt.Fprintf(os.Stderr, "numerals: dropped %d rune(s) with a NaN or infinite value\n", nonFinite) + } return nil } diff --git a/mangling/ucd/cmd/gen_numerals/gen_numerals_test.go b/mangling/ucd/cmd/gen_numerals/gen_numerals_test.go new file mode 100644 index 0000000..0bb3029 --- /dev/null +++ b/mangling/ucd/cmd/gen_numerals/gen_numerals_test.go @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/go-openapi/codegen/mangling/ucd/internal/locate" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// numericValuesFixture is a hand-picked slice of DerivedNumericValues.txt: one kept line per category, the categories +// the generator drops, a range line, and two malformed lines. +const numericValuesFixture = `# DerivedNumericValues-15.0.0.txt +# a comment line + +0030 ; 0.0 ; ; 0 # Nd DIGIT ZERO +4E00 ; 1.0 ; ; 1 # Lo CJK UNIFIED IDEOGRAPH-4E00 +00BD ; 0.5 ; ; 1/2 # No VULGAR FRACTION ONE HALF +2461 ; 2.0 ; ; 2 # No CIRCLED DIGIT TWO +2160 ; 1.0 ; ; 1 # Nl ROMAN NUMERAL ONE +11FC9..11FCA ; 0.0625 ; ; 1/16 # No [2] TAMIL FRACTION ONE SIXTEENTH-1..TAMIL FRACTION ONE SIXTEENTH-2 +0BF0 ; ? ; ; ? # No BROKEN VALUE +0BF1 ; nan ; ; ? # No NO NUMERIC VALUE +0BF2 ; inf ; ; ? # No INFINITE VALUE +ZZZZ ; 3.0 ; ; 3 # No BROKEN CODEPOINT +2460 no comment here +` + +func TestParse(t *testing.T) { + t.Parallel() + + fields, cat, ok := parse("00BD ; 0.5 ; ; 1/2 # No VULGAR FRACTION ONE HALF") + require.True(t, ok) + assert.Equal(t, "No", cat) + require.Len(t, fields, 4) + assert.Equal(t, "00BD", strings.TrimSpace(fields[0])) + assert.Equal(t, "0.5", strings.TrimSpace(fields[1])) + + // Lines the parser rejects: no trailing comment, too few fields, empty comment. + for _, line := range []string{ + "00BD ; 0.5 ; ; 1/2", + "00BD ; 0.5 # No", + "00BD ; 0.5 ; ; 1/2 #", + } { + _, _, ok := parse(line) + assert.Falsef(t, ok, "parse(%q) should reject", line) + } +} + +func TestCodeRange(t *testing.T) { + t.Parallel() + + lo, hi, err := codeRange("1F100") + require.NoError(t, err) + assert.Equal(t, rune(0x1F100), lo) + assert.Equal(t, rune(0x1F100), hi, "a single codepoint is its own upper bound") + + lo, hi, err = codeRange("11FC9..11FCA") + require.NoError(t, err) + assert.Equal(t, rune(0x11FC9), lo) + assert.Equal(t, rune(0x11FCA), hi) + + for _, s := range []string{"ZZZZ", "11FC9..ZZZZ", ""} { + _, _, err := codeRange(s) + assert.Errorf(t, err, "codeRange(%q) should fail", s) + } +} + +// TestGenerateNumerals runs the generator over the fixture and reads the emitted table back. +func TestGenerateNumerals(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + ucdDir := filepath.Join(dir, "v15") + require.NoError(t, os.MkdirAll(ucdDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(ucdDir, ucdFile), []byte(numericValuesFixture), 0o600)) + + out := filepath.Join(dir, "numerals15.0.0.go") + require.NoError(t, run("numbers", out, ucdDir, "!go1.27")) + + src, err := os.ReadFile(out) + require.NoError(t, err) + + file, err := parser.ParseFile(token.NewFileSet(), out, src, parser.ParseComments) + require.NoError(t, err, "the generated table must be valid Go") + assert.Equal(t, "numbers", file.Name.Name) + assert.Contains(t, string(src), "//go:build !go1.27") + assert.Contains(t, string(src), "Generated from v15/DerivedNumericValues.txt") + + nums := numeralTable(t, file) + + t.Run("keeps No and Nl", func(t *testing.T) { + assert.Equal(t, 0.5, nums['½']) + assert.Equal(t, 2.0, nums['②']) + assert.Equal(t, 1.0, nums['Ⅰ']) + }) + + t.Run("drops Nd digits and Lo ideographs", func(t *testing.T) { + _, ok := nums['0'] + assert.False(t, ok, "Nd digits are handled by the digit offset") + _, ok = nums['一'] + assert.False(t, ok, "Lo (CJK) numbers are elided") + }) + + t.Run("expands a range into one entry per rune", func(t *testing.T) { + assert.Equal(t, 0.0625, nums[0x11FC9]) + assert.Equal(t, 0.0625, nums[0x11FCA]) + }) + + t.Run("skips malformed lines", func(t *testing.T) { + _, ok := nums[0x0BF0] + assert.False(t, ok, "an unparseable value is skipped") + }) + + // ParseFloat accepts "nan" and "inf", but Go has no literal for either: FormatFloat would emit the identifiers + // NaN and +Inf, and the table would not compile. + t.Run("skips NaN and infinite values", func(t *testing.T) { + for _, r := range []rune{0x0BF1, 0x0BF2} { + _, ok := nums[r] + assert.Falsef(t, ok, "U+%04X carries a non-finite value and must be dropped", r) + } + assert.NotContains(t, string(src), "NaN") + assert.NotContains(t, string(src), "Inf") + assert.Len(t, nums, 5, "only the well-formed, finite No/Nl lines are kept") + }) +} + +func TestGenerateNumeralsMissingSource(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := run("numbers", filepath.Join(dir, "out.go"), filepath.Join(dir, "v15"), "") + require.Error(t, err, "a missing extract must be reported, not silently produce an empty table") +} + +// numeralTable reads the generated `var runeNumericValue = map[rune]float64{…}` back out of the emitted source. +func numeralTable(t *testing.T, file *ast.File) map[rune]float64 { + t.Helper() + + out := map[rune]float64{} + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || value.Names[0].Name != "runeNumericValue" { + continue + } + lit, ok := value.Values[0].(*ast.CompositeLit) + require.True(t, ok, "runeNumericValue is not a composite literal") + + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + require.True(t, ok, "runeNumericValue holds a non key/value element") + + key, ok := kv.Key.(*ast.BasicLit) + require.True(t, ok, "map key is not a literal") + r, err := strconv.ParseInt(key.Value, 0, 32) + require.NoErrorf(t, err, "map key %q", key.Value) + + val, ok := kv.Value.(*ast.BasicLit) + require.True(t, ok, "map value is not a literal") + v, err := strconv.ParseFloat(val.Value, 64) + require.NoErrorf(t, err, "map value %q", val.Value) + + out[rune(r)] = v + } + } + } + require.NotEmpty(t, out, "no runeNumericValue table in the generated source") + + return out +} + +// TestResolveArgs covers the [package [outbase [version [ucd-root]]]] command line. +// +// It swaps os.Args, so it does not run in parallel. +func TestResolveArgs(t *testing.T) { + saved := os.Args + t.Cleanup(func() { os.Args = saved }) + + t.Run("defaults to the numbers package and the baseline version", func(t *testing.T) { + if _, err := locate.UCDRoot(); err != nil { + t.Skipf("no git checkout to resolve the default UCD root from: %v", err) + } + + os.Args = []string{"gen_numerals"} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + baseline := locate.Versions[0] + assert.Equal(t, "numbers", pkg) + assert.Equal(t, "numerals"+baseline.UCD+".go", outFile) + assert.Equal(t, baseline.Dir, filepath.Base(ucdDir)) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) + + t.Run("takes every argument", func(t *testing.T) { + root := t.TempDir() + baseline := locate.Versions[0] + + os.Args = []string{"gen_numerals", "otherpkg", "othertable", baseline.UCD, root} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + assert.Equal(t, "otherpkg", pkg) + assert.Equal(t, "othertable"+baseline.UCD+".go", outFile, "the version is appended to the output base name") + assert.Equal(t, filepath.Join(root, baseline.Dir), ucdDir) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) +} diff --git a/mangling/ucd/cmd/gen_runewords/gen_runewords.go b/mangling/ucd/cmd/gen_runewords/gen_runewords.go index b6d5107..2203d84 100644 --- a/mangling/ucd/cmd/gen_runewords/gen_runewords.go +++ b/mangling/ucd/cmd/gen_runewords/gen_runewords.go @@ -175,6 +175,9 @@ func run(pkg, outFile, ucdDir, buildTag string) error { if err != nil { return err } + // The generated file records this path, so keep it slash-separated: regenerating on Windows must not rewrite + // "v15/DerivedName.txt" as "v15\DerivedName.txt" and churn every table. + inFile = filepath.ToSlash(inFile) if err := emit(inFile, pkg, outFile, buildTag, entries, idOf, order, blob.String(), offsets); err != nil { return err diff --git a/mangling/ucd/cmd/gen_runewords/gen_runewords_test.go b/mangling/ucd/cmd/gen_runewords/gen_runewords_test.go new file mode 100644 index 0000000..ffbaf5d --- /dev/null +++ b/mangling/ucd/cmd/gen_runewords/gen_runewords_test.go @@ -0,0 +1,379 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "testing" + + "github.com/go-openapi/codegen/mangling/ucd/internal/locate" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// derivedNameFixture is a hand-picked slice of DerivedName.txt: two consecutive kept runes (so the emitter builds a +// multi-rune run), one kept rune per summarization rule, and one line per exclusion reason. +const derivedNameFixture = `# DerivedName-15.0.0.txt +# a comment line + +0041 ; LATIN CAPITAL LETTER A +00E9 ; LATIN SMALL LETTER E WITH ACUTE +0301 ; COMBINING ACUTE ACCENT +0030 ; DIGIT ZERO +00BD ; VULGAR FRACTION ONE HALF +0020 ; SPACE +03B1 ; GREEK SMALL LETTER ALPHA +03B2 ; GREEK SMALL LETTER BETA +0416 ; CYRILLIC CAPITAL LETTER ZHE +20AC ; EURO SIGN +2713 ; CHECK MARK +2764 ; HEAVY BLACK HEART +4E00 ; CJK UNIFIED IDEOGRAPH-4E00 +AC00 ; HANGUL SYLLABLE GA +1F600 ; GRINNING FACE +3400..4DBF ; CJK UNIFIED IDEOGRAPH-* +` + +// emojiDataFixture mirrors emoji-data.txt: the property name butts up against the '#' comment, as it does in the real +// extract, and the file mixes single codepoints with ranges. +const emojiDataFixture = `# emoji-data.txt +# All omitted code points have Extended_Pictographic=No + +0023 ; Emoji # E0.0 [1] (#) hash sign +2764 ; Extended_Pictographic# E0.6 [1] (❤) red heart +1F600..1F64F ; Extended_Pictographic# E1.0 [80] (😀..🙏) grinning face..folded hands +` + +func TestCollapse(t *testing.T) { + t.Parallel() + + // One or two words are kept whole; 3+ words reduce to the longest word that is neither glue nor a qualifier. + for in, want := range map[string]string{ + "alpha": "alpha", + "grinning face": "grinning face", + "heavy black heart": "heart", + "place of sajdah": "sajdah", + "fehu feoh fe f": "fehu", + "black white heavy": "heavy", // all words skipped: fall back to the last one + } { + assert.Equalf(t, want, collapse(in), "collapse(%q)", in) + } +} + +func TestSummarize(t *testing.T) { + t.Parallel() + + // Names a taxonomy rule matches: LETTER / SYLLABLE / CHARACTER / NUMBER, then SIGN|SYMBOL. + for name, want := range map[string]string{ + "GREEK SMALL LETTER ALPHA": "alpha", + "CYRILLIC CAPITAL LETTER ZHE": "zhe", + "GREEK SMALL LETTER LAMDA": "lambda", // wordOverrides fixes Unicode's spelling + "KATAKANA LETTER SMALL A": "small a", + "HIRAGANA LETTER A WITH DAKUTEN": "a", // the "WITH …" diacritic tail is dropped + "HANGUL SYLLABLE GA": "ga", + "THAI CHARACTER KO KAI": "ko kai", + "VULGAR FRACTION ONE HALF": "one half", + "GREEK BETA SYMBOL": "greek beta", + "EURO SIGN": "euro", + "ROMAN NUMERAL ONE": "one", + "ARABIC PLACE OF SAJDAH": "sajdah", // the script prefix is peeled, then 3 words collapse + } { + got, ok := summarize(name) + assert.Truef(t, ok, "summarize(%q) should match a rule", name) + assert.Equalf(t, want, got, "summarize(%q)", name) + } + + // No rule matches: the whole name is kept as a failsafe, and the caller is told. + got, ok := summarize("GRINNING FACE") + assert.False(t, ok, "summarize should report an unmatched name") + assert.Equal(t, "grinning face", got) +} + +func TestStripScriptPrefix(t *testing.T) { + t.Parallel() + + got, ok := stripScriptPrefix("ARABIC PLACE OF SAJDAH") + assert.True(t, ok) + assert.Equal(t, "PLACE OF SAJDAH", got) + + // The last word is never stripped, and a name that does not start with a script token is left alone. + got, ok = stripScriptPrefix("GREEK") + assert.False(t, ok) + assert.Equal(t, "GREEK", got) + + got, ok = stripScriptPrefix("HEAVY BLACK HEART") + assert.False(t, ok) + assert.Equal(t, "HEAVY BLACK HEART", got) +} + +// TestPictographicGate covers loading emoji-data.txt and the membership search over it. +// +// It writes the package-level pictRanges, so it does not run in parallel (see TestGenerateRunewords). +func TestPictographicGate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ucdEmojiFile) + require.NoError(t, os.WriteFile(path, []byte(emojiDataFixture), 0o600)) + + ranges, err := loadPictographic(path) + require.NoError(t, err) + require.Len(t, ranges, 2, "only Extended_Pictographic lines are loaded") + assert.Equal(t, rrange{0x2764, 0x2764}, ranges[0]) + assert.Equal(t, rrange{0x1F600, 0x1F64F}, ranges[1]) + + pictRanges = ranges + t.Cleanup(func() { pictRanges = nil }) + + for _, r := range []rune{'❤', '😀', '🙏', 0x1F64F} { + assert.Truef(t, isPictographic(r), "%q should be pictographic", r) + } + for _, r := range []rune{'#', '✓', 0x2763, 0x2765, 0x1F5FF, 0x1F650} { + assert.Falsef(t, isPictographic(r), "%q should not be pictographic", r) + } + + _, err = loadPictographic(filepath.Join(dir, "absent.txt")) + require.Error(t, err) +} + +// TestGenerateRunewords runs the generator over the fixtures and decodes the emitted tables the way runewords.Word +// does, so the whole pipeline — classify, summarize, intern, interval-encode — is checked end to end. +// +// It is not parallel: run() sets the package-level pictRanges. +func TestGenerateRunewords(t *testing.T) { + dir := t.TempDir() + ucdDir := filepath.Join(dir, "v15") + require.NoError(t, os.MkdirAll(ucdDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(ucdDir, ucdFile), []byte(derivedNameFixture), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(ucdDir, ucdEmojiFile), []byte(emojiDataFixture), 0o600)) + t.Cleanup(func() { pictRanges = nil }) + + out := filepath.Join(dir, "tables15.0.0.go") + require.NoError(t, run("runewords", out, ucdDir, "go1.27")) + + src, err := os.ReadFile(out) + require.NoError(t, err) + + file, err := parser.ParseFile(token.NewFileSet(), out, src, parser.ParseComments) + require.NoError(t, err, "the generated tables must be valid Go") + assert.Equal(t, "runewords", file.Name.Name) + assert.Contains(t, string(src), "//go:build go1.27") + assert.Contains(t, string(src), "Generated from v15/DerivedName.txt") + + tbl := readTables(t, file) + + t.Run("covered runes decode to their word", func(t *testing.T) { + for r, want := range map[rune]string{ + 'α': "alpha", + 'β': "beta", + 'Ж': "zhe", + '€': "euro", + '❤': "heart", + '😀': "grinning face", + } { + got, ok := tbl.word(r) + assert.Truef(t, ok, "U+%04X should be covered", r) + assert.Equalf(t, want, got, "word(%q)", r) + } + }) + + t.Run("excluded runes are absent", func(t *testing.T) { + for name, r := range map[string]rune{ + "ascii": 'A', + "latin": 'é', + "combining": 0x0301, + "digit": '0', + "numeral": '½', + "separator": ' ', + "han": '一', + "hangul": '가', + "block": '✓', // Dingbats, and not Extended_Pictographic + } { + _, ok := tbl.word(r) + assert.Falsef(t, ok, "%s rune U+%04X should not be covered", name, r) + } + }) + + t.Run("the encoding is well formed", func(t *testing.T) { + require.Len(t, tbl.runFirstIndex, len(tbl.runStart)+1, "runFirstIndex must carry a trailing sentinel") + assert.Equal(t, uint32(0), tbl.runFirstIndex[0]) + assert.Equal(t, uint32(len(tbl.wordID)), tbl.runFirstIndex[len(tbl.runFirstIndex)-1], "the sentinel is the rune count") //nolint:gosec // the fixture holds a handful of runes, so the count fits in uint32 + assert.Len(t, tbl.wordID, 6, "six runes survive classification") + + // α and β are consecutive, so they share one run; the other four runes stand alone. + assert.Len(t, tbl.runStart, 5) + assert.Equal(t, uint32('α'), tbl.runStart[0]) + assert.Equal(t, uint32(2), tbl.runFirstIndex[1], "the greek run holds two runes") + + for i := 1; i < len(tbl.runStart); i++ { + assert.Lessf(t, tbl.runStart[i-1], tbl.runStart[i], "runStart not ascending at %d", i) + assert.Lessf(t, tbl.runFirstIndex[i-1], tbl.runFirstIndex[i], "runFirstIndex not ascending at %d", i) + } + + assert.Equal(t, uint32(0), tbl.offset(0)) + assert.Equal(t, uint32(len(tbl.blob)), tbl.offset(uint32(len(tbl.offLo)-1)), "the last offset closes the blob") //nolint:gosec // the fixture blob is a few dozen bytes long + }) + + t.Run("words are interned once", func(t *testing.T) { + assert.Equal(t, "alphabetazheeuroheartgrinning face", tbl.blob) + }) +} + +func TestGenerateRunewordsMissingSource(t *testing.T) { + dir := t.TempDir() + t.Cleanup(func() { pictRanges = nil }) + + ucdDir := filepath.Join(dir, "v15") + require.NoError(t, os.MkdirAll(ucdDir, 0o750)) + + err := run("runewords", filepath.Join(dir, "out.go"), ucdDir, "") + require.Error(t, err, "a missing emoji-data.txt must be reported") + + require.NoError(t, os.WriteFile(filepath.Join(ucdDir, ucdEmojiFile), []byte(emojiDataFixture), 0o600)) + err = run("runewords", filepath.Join(dir, "out.go"), ucdDir, "") + require.Error(t, err, "a missing DerivedName.txt must be reported") +} + +// tables holds the generated arrays, read back from the emitted source. +type tables struct { + blob string + offLo []uint32 + offHi []uint32 + runStart []uint32 + runFirstIndex []uint32 + wordID []uint32 +} + +// offset reconstructs an 18-bit blob offset from the uint16 low array and the 2-bit high sidecar, as +// runewords.offset18 does. +func (tb tables) offset(id uint32) uint32 { + hi := (tb.offHi[id>>2] >> (2 * (id & 3))) & 0x3 + + return hi<<16 | tb.offLo[id] +} + +// word looks a rune up through the interval encoding, mirroring runewords.Word. +func (tb tables) word(r rune) (string, bool) { + u := uint32(r) //nolint:gosec // false positive: rune aliases to int32, so it is okay to consider the result unsigned + + i := sort.Search(len(tb.runStart), func(i int) bool { return tb.runStart[i] > u }) + if i == 0 { + return "", false + } + i-- + + pos := tb.runFirstIndex[i] + (u - tb.runStart[i]) + if pos >= tb.runFirstIndex[i+1] { + return "", false + } + + id := tb.wordID[pos] + + return tb.blob[tb.offset(id):tb.offset(id+1)], true +} + +func readTables(t *testing.T, file *ast.File) tables { + t.Helper() + + return tables{ + blob: stringConst(t, file, "wordBlob"), + offLo: uintSlice(t, file, "wordOffLo"), + offHi: uintSlice(t, file, "wordOffHi"), + runStart: uintSlice(t, file, "runStart"), + runFirstIndex: uintSlice(t, file, "runFirstIndex"), + wordID: uintSlice(t, file, "nameWordID"), + } +} + +// stringConst returns the value of the generated string constant named name. +func stringConst(t *testing.T, file *ast.File, name string) string { + t.Helper() + + lit, ok := declValue(file, token.CONST, name).(*ast.BasicLit) + require.Truef(t, ok, "const %s is not a literal", name) + s, err := strconv.Unquote(lit.Value) + require.NoErrorf(t, err, "const %s", name) + + return s +} + +// uintSlice returns the elements of the generated integer slice named name. +func uintSlice(t *testing.T, file *ast.File, name string) []uint32 { + t.Helper() + + lit, ok := declValue(file, token.VAR, name).(*ast.CompositeLit) + require.Truef(t, ok, "var %s is not a composite literal", name) + + out := make([]uint32, 0, len(lit.Elts)) + for _, elt := range lit.Elts { + e, ok := elt.(*ast.BasicLit) + require.Truef(t, ok, "var %s holds a non-literal element", name) + v, err := strconv.ParseUint(e.Value, 0, 32) + require.NoErrorf(t, err, "var %s element %q", name, e.Value) + out = append(out, uint32(v)) + } + require.NotEmptyf(t, out, "var %s is empty", name) + + return out +} + +// declValue returns the expression assigned to the package-level const or var named name. +func declValue(file *ast.File, tok token.Token, name string) ast.Expr { + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != tok { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || value.Names[0].Name != name || len(value.Values) != 1 { + continue + } + + return value.Values[0] + } + } + + return nil +} + +// TestResolveArgs covers the [package [outbase [version [ucd-root]]]] command line. +// +// It swaps os.Args, so it does not run in parallel. +func TestResolveArgs(t *testing.T) { + saved := os.Args + t.Cleanup(func() { os.Args = saved }) + + t.Run("defaults to the runewords package and the baseline version", func(t *testing.T) { + if _, err := locate.UCDRoot(); err != nil { + t.Skipf("no git checkout to resolve the default UCD root from: %v", err) + } + + os.Args = []string{"gen_runewords"} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + baseline := locate.Versions[0] + assert.Equal(t, "runewords", pkg) + assert.Equal(t, "tables"+baseline.UCD+".go", outFile) + assert.Equal(t, baseline.Dir, filepath.Base(ucdDir)) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) + + t.Run("takes every argument", func(t *testing.T) { + root := t.TempDir() + baseline := locate.Versions[0] + + os.Args = []string{"gen_runewords", "otherpkg", "othertable", baseline.UCD, root} + pkg, outFile, ucdDir, buildTag := resolveArgs() + + assert.Equal(t, "otherpkg", pkg) + assert.Equal(t, "othertable"+baseline.UCD+".go", outFile, "the version is appended to the output base name") + assert.Equal(t, filepath.Join(root, baseline.Dir), ucdDir) + assert.Equal(t, locate.BuildConstraint(0), buildTag) + }) +} diff --git a/mangling/ucd/go.mod b/mangling/ucd/go.mod index 83de732..fd7be35 100644 --- a/mangling/ucd/go.mod +++ b/mangling/ucd/go.mod @@ -1,3 +1,5 @@ module github.com/go-openapi/codegen/mangling/ucd go 1.26.0 + +require github.com/go-openapi/testify/v2 v2.7.0 diff --git a/mangling/ucd/go.sum b/mangling/ucd/go.sum new file mode 100644 index 0000000..0de8fc3 --- /dev/null +++ b/mangling/ucd/go.sum @@ -0,0 +1,2 @@ +github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= +github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= diff --git a/mangling/ucd/internal/locate/root.go b/mangling/ucd/internal/locate/root.go index 3ee523c..ed46ec3 100644 --- a/mangling/ucd/internal/locate/root.go +++ b/mangling/ucd/internal/locate/root.go @@ -162,5 +162,5 @@ func UCDRoot() (string, error) { return r == '\n' || r == '\r' || unicode.IsSpace(r) })) - return filepath.Join(root, "mangling", "v2", "ucd"), nil // TODO: temporary location + return filepath.Join(root, "mangling", "ucd"), nil } diff --git a/mangling/ucd/internal/locate/root_test.go b/mangling/ucd/internal/locate/root_test.go new file mode 100644 index 0000000..aa9f437 --- /dev/null +++ b/mangling/ucd/internal/locate/root_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package locate + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestBuildConstraint covers the four shapes a derived //go:build expression can take. +// +// It swaps the package-level [Versions] registry, so it does not run in parallel. +func TestBuildConstraint(t *testing.T) { + saved := Versions + t.Cleanup(func() { Versions = saved }) + + t.Run("a lone version carries no constraint", func(t *testing.T) { + Versions = []Version{{UCD: "15.0.0", Dir: "v15"}} + assert.Equal(t, "", BuildConstraint(0)) + }) + + t.Run("two versions bound each other", func(t *testing.T) { + Versions = []Version{ + {UCD: "15.0.0", Dir: "v15"}, + {UCD: "17.0.0", Dir: "v17", MinGo: "go1.27"}, + } + assert.Equal(t, "!go1.27", BuildConstraint(0)) + assert.Equal(t, "go1.27", BuildConstraint(1)) + }) + + t.Run("a middle version is bounded on both sides", func(t *testing.T) { + Versions = []Version{ + {UCD: "15.0.0", Dir: "v15"}, + {UCD: "17.0.0", Dir: "v17", MinGo: "go1.27"}, + {UCD: "19.0.0", Dir: "v19", MinGo: "go1.29"}, + } + assert.Equal(t, "!go1.27", BuildConstraint(0)) + assert.Equal(t, "go1.27 && !go1.29", BuildConstraint(1)) + assert.Equal(t, "go1.29", BuildConstraint(2)) + }) +} + +// TestShippedVersions checks the registry that ships: exactly one baseline, then strictly increasing Go bounds, so the +// derived constraints select exactly one flavor for any toolchain. +func TestShippedVersions(t *testing.T) { + t.Parallel() + + require.NotEmpty(t, Versions) + assert.Equal(t, "", Versions[0].MinGo, "the first version is the baseline and takes no lower bound") + + for i, v := range Versions { + assert.NotEmptyf(t, v.UCD, "version %d has no UCD string", i) + assert.NotEmptyf(t, v.Dir, "version %d has no data directory", i) + + if i > 0 { + require.NotEmptyf(t, v.MinGo, "version %s must declare a Go baseline", v.UCD) + assert.Greaterf(t, goMinor(v.MinGo), goMinor(Versions[i-1].MinGo), + "version %s does not raise the Go baseline of %s", v.UCD, Versions[i-1].UCD, + ) + } + + got, idx, ok := Lookup(v.UCD) + require.Truef(t, ok, "Lookup(%q) missed", v.UCD) + assert.Equal(t, i, idx) + assert.Equal(t, v, got) + } + + _, _, ok := Lookup("14.0.0") + assert.False(t, ok, "an unregistered version must not resolve") +} + +func TestGoMinor(t *testing.T) { + t.Parallel() + + for in, want := range map[string]int{ + "go1.26": 26, + "go1.26.4": 26, + "go1.27rc1": 27, + "go1.27beta2": 27, + "1.27": 27, + "go1": 0, + "devel": 0, + "": 0, + "go1.x": 0, + "go1.28.0-foo": 28, + } { + assert.Equalf(t, want, goMinor(in), "goMinor(%q)", in) + } +} + +func TestResolve(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + t.Run("resolves the baseline against an overridden root", func(t *testing.T) { + t.Parallel() + + dir, tag, suffix, err := Resolve(Versions[0].UCD, root) + require.NoError(t, err) + assert.Equal(t, filepath.Join(root, Versions[0].Dir), dir) + assert.Equal(t, BuildConstraint(0), tag) + assert.Equal(t, Versions[0].UCD, suffix) + }) + + t.Run("rejects an unknown version, listing the known ones", func(t *testing.T) { + t.Parallel() + + _, _, _, err := Resolve("14.0.0", root) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown UCD version "14.0.0"`) + assert.Contains(t, err.Error(), Versions[0].UCD) + }) + + t.Run("refuses a dataset the running toolchain is too old for", func(t *testing.T) { + t.Parallel() + + for _, v := range Versions[1:] { + _, _, _, err := Resolve(v.UCD, root) + if goMinor(runtime.Version()) >= goMinor(v.MinGo) { + require.NoErrorf(t, err, "%s is served by %s", v.UCD, runtime.Version()) + + continue + } + + require.Errorf(t, err, "%s targets %s and must be refused under %s", v.UCD, v.MinGo, runtime.Version()) + assert.Contains(t, err.Error(), v.MinGo) + } + }) +} + +// TestUCDRootHoldsTheExtracts checks the git-derived default root actually points at the shipped data: every version +// directory in [Versions] must be there with the three extracts the generators read. +func TestUCDRootHoldsTheExtracts(t *testing.T) { + t.Parallel() + + root, err := UCDRoot() + if err != nil { + t.Skipf("no git checkout to resolve the UCD root from: %v", err) + } + + for _, v := range Versions { + for _, name := range []string{"DerivedName.txt", "DerivedNumericValues.txt", "emoji-data.txt"} { + path := filepath.Join(root, v.Dir, name) + _, err := os.Stat(path) + assert.NoErrorf(t, err, "UCD %s: %s is missing from the resolved root", v.UCD, path) + } + } +}