Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,54 @@ because it turns other people's test suites red.

### Added

- **Three more presets: `upload-validation`, `text-encoding` and
`tabular-import`.** Run `tfg preset list` for all five, or
`tfg preset show <id>` for what one takes and what it would produce before
it writes anything.

**`upload-validation`** answers "does my upload form take what it should and
turn the rest away?" - 71 files in eight groups at its defaults, 115 MB. A
file a byte under your limit, one at it, one a byte over and one twice it. A
real file of every type you allow, which is the positive control. A file for
every extension you deny, an SVG and an HTML among them, because both are
routinely taken for a picture and for plain text. A PDF named `.jpg`. A file
with no extension, one named `PHOTO.JPG` and one named `invoice.jpg.exe`. A
name 204 characters long, a name outside ASCII, and a name with spaces and
brackets that is perfectly legal. And fifty files at once.

`--limit` is the size your form declares, and the run says out loud when you
did not give one, because a set built around our placeholder says nothing
about your form. `--allow` and `--deny` are lists with commas. An extension
in `--deny` that this build has no format for - `exe`, `sh` - still gets a
file under that name, holding plain text, and the run says so: it tests a
form reading the end of a name, not one reading what is inside.
`--far-over 10x` asks for a file ten times the limit rather than twice it,
`--far-over off` leaves it out, and `--bulk 0` leaves the mass upload out.
Anything a setting empties is named in the output rather than quietly
missing. What this set does **not** do is put a path in a file name:
`../../etc/passwd` is not a name this tool will write, deliberately.

**`text-encoding`** answers "does my reader know which encoding a file is
in, or is it guessing?" - 20 files, 80 kB. TXT, MD and XML in UTF-8,
UTF-16LE and UTF-16BE, each with and without a byte order mark, plus CSV and
LOG with LF and with CRLF endings. UTF-8 expects `accept` with or without a
mark. UTF-16 expects `unspecified`: whether your system handles it at all is
your policy, and the manifest does not invent it. Two combinations are left
out and said out loud, because XML in UTF-16 has to open with a mark. No
format in this build carries an encoding and a line ending at once, so the
two halves are separate files rather than one grid - the run says that too.
`--sample` sets how big each file is, and refuses an odd number, because a
file in UTF-16 always has an even number of bytes.

**`tabular-import`** answers "does my table import survive what real tools
export?" - 13 files, 2.9 MB. One CSV per dialect: comma, semicolon, pipe and
tab, LF and CRLF, with and without a header row, and three quoting styles,
one setting at a time against a base so a failure names its cause. A CSV
with more columns than a spreadsheet will show, which expects `unspecified`
with `count_limit`. A spreadsheet of `--rows` by `--columns`, written at
exactly the size that many cells package to. And the same JSON records
written indented, minified and one to a line.

- **A second preset: `empty-and-minimal`.** It answers "does a file that is
valid and as small as the format allows get through?" and builds the
smallest legal file of every format this build has, plus a file of nought
Expand Down
75 changes: 60 additions & 15 deletions internal/cli/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"strings"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
Expand Down Expand Up @@ -233,23 +234,30 @@ func presetNamed(args []string) string {
// for a flag - a parse that succeeded means every name was defined. Names the
// command does define are skipped for the same reason: with --preset given,
// --limit is defined, and the failure was about something else entirely.
func parameterWithoutItsPreset(fs *flag.FlagSet, args []string) (name, owner string) {
func parameterWithoutItsPreset(fs *flag.FlagSet, args []string) (name string, owners []string) {
for _, a := range args {
if a == "--" {
return "", ""
return "", nil
}
if !strings.HasPrefix(a, "-") {
continue
}
candidate, _, _ := strings.Cut(strings.TrimLeft(a, "-"), "=")
if fs.Lookup(candidate) != nil {
continue
}
if owner := preset.Declaring(candidate); owner != "" {
return candidate, owner
candidate, declared := presetParameterIn(fs, a)
if len(declared) > 0 {
return candidate, declared
}
}
return "", ""
return "", nil
}

// presetParameterIn is the preset parameter one argument names, and every
// preset that declares it.
func presetParameterIn(fs *flag.FlagSet, arg string) (string, []string) {
if !strings.HasPrefix(arg, "-") {
return "", nil
}
candidate, _, _ := strings.Cut(strings.TrimLeft(arg, "-"), "=")
if fs.Lookup(candidate) != nil {
return "", nil
}
return candidate, preset.Declaring(candidate)
}

// addPresetFlags puts the parameters of the named preset on the generate flag
Expand Down Expand Up @@ -281,19 +289,56 @@ func addPresetFlags(fs *flag.FlagSet, args []string, errOut io.Writer) int {
// It reports whether it answered, so the caller knows whether the complaint it
// held back still has to be let through.
func explainUndefinedFlag(fs *flag.FlagSet, args []string, errOut io.Writer) bool {
name, owner := parameterWithoutItsPreset(fs, args)
name, owners := parameterWithoutItsPreset(fs, args)
if name == "" {
return false
}
// The second sentence names both roads, because since 2026-09-22 a
// recipe file can build on the preset too - and beside a file the flag
// does not exist either, the file's with section is where the value goes.
//
// Every owner is named, because since 2026-09-22 two presets ask for the
// limit a system declares and both call it that. Naming one of them sent
// the reader who meant the other to add the wrong preset, in a sentence
// that read as certain.
fmt.Fprintf(errOut,
"tfg: --%s is a parameter of the preset %s, so it only exists beside it. Add --preset %s, put %s under with: in a recipe that extends it, or drop --%s.\n",
name, owner, owner, name, name)
"tfg: --%s is a parameter of %s, so it only exists beside %s. Add %s, put %s under with: in a recipe that extends %s, or drop --%s.\n",
name, presetsNamed(owners), oneOfThem(owners), presetFlagsFor(owners),
name, oneOfThem(owners), name)
return true
}

// presetsNamed, oneOfThem and presetFlagsFor word one sentence for one owner
// and for several, so it reads as English either way rather than as a list with
// a noun beside it that does not agree.
func presetsNamed(owners []string) string {
return core.Noun(len(owners), "the preset ", "the presets ") + joinWithOr(owners)
}

func oneOfThem(owners []string) string {
return core.Noun(len(owners), "it", "one of them")
}

func presetFlagsFor(owners []string) string {
flags := make([]string, 0, len(owners))
for _, id := range owners {
flags = append(flags, "--preset "+id)
}
return joinWithOr(flags)
}

// joinWithOr writes a list the way a sentence takes one. A comma between every
// pair reads as an enumeration and this sentence is prose.
func joinWithOr(items []string) string {
switch len(items) {
case 0:
return ""
case 1:
return items[0]
}
return strings.Join(items[:len(items)-1], ", ") + " or " + items[len(items)-1]
}

// describingFlagsBeside is describingFlagsGiven minus what this preset reads.
//
// A preset lays out a whole set, so a flag describing one target has no target
Expand Down
7 changes: 6 additions & 1 deletion internal/guard/branching_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ const (
// more than the depth does.
// Lowered from 52 on 2026-09-05: splitting preflight out took one function
// out of the band. The ratchet only tightens.
crowdedDepthFunctions = 51
// Lowered from 51 on 2026-09-22: the scan for a preset parameter typed
// without its preset became two functions when it learnt to answer with
// every owner, and the loop left behind is two deep rather than three. The
// four presets that arrived the same day were flattened to hold the number
// where it was - this is the one that went below it.
crowdedDepthFunctions = 50

// An axis this set does not watch. crowding() asks n >= band, so nothing
// reaches it.
Expand Down
48 changes: 30 additions & 18 deletions internal/guard/generatewindow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,7 @@ func TestTheWindowDrawsAFieldForEveryDeclaredProperty(t *testing.T) {
if bad := wrongKindOfControl(p, control); bad != "" {
t.Errorf("%s.%s is %s", d.ID, p.Name, bad)
}
// A closed set says what it takes with its menu rather than in
// prose, since 2026-08-19 (O105). What it still has to say is what
// it is FOR - the sentence spelling twenty format names out under
// a menu offering the same twenty was two lines of duplication on
// a screen that does not fit as it is.
want := p.Allowed()
if p.Kind == format.PropertyChoice {
want = p.Detail
}
if shown := everythingSaid(content); want != "" && !strings.Contains(shown, want) {
t.Errorf("the field for %s.%s does not say %q", d.ID, p.Name, want)
}
if p.Kind == format.PropertyChoice {
if shown := everythingSaid(content); strings.Contains(shown, p.Allowed()) {
t.Errorf("the field for %s.%s lists its values in prose (%q) as well as in the "+
"menu above them", d.ID, p.Name, p.Allowed())
}
}
saysWhatItTakes(t, everythingSaid(content), d.ID, p)
checked++
}

Expand Down Expand Up @@ -203,6 +186,35 @@ func declares(d format.Descriptor, name string) bool {
return false
}

// saysWhatItTakes checks the sentence under a field against the declaration it
// was drawn from.
//
// A closed set says what it takes with its menu rather than in prose, since
// 2026-08-19 (O105). What it still has to say is what it is FOR - the sentence
// spelling twenty format names out under a menu offering the same twenty was
// two lines of duplication on a screen that does not fit as it is.
//
// Shared with the preset screen since 2026-09-22, when the first preset
// parameter with a closed set arrived and the screen it is on asked for the
// prose the other screen had been told not to write. One rule, one place: two
// screens drawing a field from one declaration cannot be judged by two rules
// without one of them being wrong.
func saysWhatItTakes(t *testing.T, shown, owner string, p format.Property) {
t.Helper()

want := p.Allowed()
if p.Kind == format.PropertyChoice {
want = p.Detail
}
if want != "" && !strings.Contains(shown, want) {
t.Errorf("the field for %s.%s does not say %q", owner, p.Name, want)
}
if p.Kind == format.PropertyChoice && strings.Contains(shown, p.Allowed()) {
t.Errorf("the field for %s.%s lists its values in prose (%q) as well as in the "+
"menu above them", owner, p.Name, p.Allowed())
}
}

// wrongKindOfControl says when a declaration got a control that cannot express
// it. A closed set drawn as a box to type in is how a value gets misspelled.
func wrongKindOfControl(p format.Property, control fyne.CanvasObject) string {
Expand Down
16 changes: 16 additions & 0 deletions internal/guard/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ var reachableFromTheWindow = []string{
// menu.
"preset:size-boundaries.format",

// The three presets of 2026-09-22, and every parameter of each. Drawn by
// the same code as the two above, because a preset parameter IS a
// format.Property - which is what made far-over, the first closed set any
// preset has declared, arrive as a menu with no window code at all.
"preset:text-encoding",
"preset:text-encoding.sample",
"preset:tabular-import",
"preset:tabular-import.rows",
"preset:tabular-import.columns",
"preset:upload-validation",
"preset:upload-validation.limit",
"preset:upload-validation.allow",
"preset:upload-validation.deny",
"preset:upload-validation.far-over",
"preset:upload-validation.bulk",

// A recipe that builds on a preset, since 2026-09-22: the switch and the
// menu on the batch screen are the extends key, and the chosen preset's
// parameters under it are the with section, drawn from the declaration
Expand Down
17 changes: 17 additions & 0 deletions internal/guard/preset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"testing"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
"github.com/donislawdev/TestingFilesGenerator/internal/preset"
"github.com/donislawdev/TestingFilesGenerator/internal/recipe"
Expand Down Expand Up @@ -46,6 +47,22 @@ func TestEveryPresetExpandsIntoARecipeThisBuildAccepts(t *testing.T) {
if target.Group == "" {
t.Errorf("target %q carries no group, so nothing can assert about the class it belongs to", target.ID)
}
// The parser does not resolve formats - the engine does, and
// "tfg validate" answers with exit 4 and the list of known
// ones. So a preset naming a format nobody registered produces
// a recipe that PARSES and a run that cannot start, and this
// guard read "the recipe it produced does not parse" and said
// nothing about it.
//
// Measured on 2026-09-22 by mutation: putting an x on the end
// of the format id in the boundary set left this guard green,
// and it had been green against that mutation on the commit
// before as well. The entry was proving nothing rather than
// something breaking.
if _, err := format.Get(target.Format); err != nil {
t.Errorf("target %q asks for the format %q and this build has none: %v",
target.ID, target.Format, err)
}
}
})
}
Expand Down
64 changes: 64 additions & 0 deletions internal/guard/presetbytes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package guard

import (
"crypto/sha256"
"encoding/hex"
"testing"

_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
"github.com/donislawdev/TestingFilesGenerator/internal/preset"
)

// What a preset ejects is bytes somebody else's manifest remembers.
//
// The manifest carries a recipe_hash, computed from the source a run consumed,
// and a preset's source is what eject prints - the same bytes, which is the
// whole of PR5. So a tidy-up that moved a space would tell everyone holding a
// record from an earlier run that their recipe had changed, and nothing in this
// tree would have said a word.
//
// PRESET-FEASIBILITY-2026-09-08.md section 5 asked for a measured gate on
// exactly one refactor: pulling the set a declared limit produces out of
// size-boundaries so that upload-validation could use it too. The measurement
// was taken by hand on 2026-09-08 and again either side of that move on
// 2026-09-22, both times 1298 B and this sum. This is that measurement kept.
//
// What to do when it goes red: decide, rather than update. The sum moving is a
// breaking change under D11 - a major, a Breaking entry in the changelog, and
// the owner's decision, because untouchable rule 12 says the assistant does not
// raise the version. A refactor that moved it is a refactor to undo.
func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) {
pinned := []struct {
id string
args preset.Args
// bytes and sum are the whole document, measured 2026-09-08 and
// unchanged since.
bytes int
sum string
}{
{
id: "size-boundaries",
args: preset.Args{"limit": "10mb", "format": "pdf"},
bytes: 1298,
sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb",
},
}

for _, want := range pinned {
expanded, err := preset.Expand(want.id, want.args)
if err != nil {
t.Errorf("%s refused %v: %v", want.id, want.args, err)
continue
}
sum := sha256.Sum256(expanded.Source)
got := hex.EncodeToString(sum[:])
if len(expanded.Source) == want.bytes && got == want.sum {
continue
}
t.Errorf("ejecting %s at %v gives %d B and %s, and it has given %d B and %s since "+
"2026-09-08.\n"+
"Every manifest written from this preset carries a hash of these bytes, so this is a "+
"breaking change under D11 rather than a number to update here.\n%s",
want.id, want.args, len(expanded.Source), got, want.bytes, want.sum, expanded.Source)
}
}
Loading
Loading