diff --git a/CHANGELOG.md b/CHANGELOG.md index fadf5d17..a08766bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ because it turns other people's test suites red. ### Changed +- **A recipe this program writes for you reads like one written by hand.** + Where the tool composes a recipe - the batch screen, and `tfg preset eject` + - a count and a size are now written as bare numbers (`size: 1024`) rather + than quoted (`size: "1024"`), and the entries of `targets` are indented + under their key. Both are what every example in the documentation looks + like, which matters because the header of an ejected recipe invites you to + edit it: a target pasted in from the documentation used to land at a + different indent and the file stopped parsing. A name made of digits stays + text, so a file called `007` keeps its name. The files a recipe produces are + unchanged to the byte. What does change is the `recipe_hash` recorded in the + manifest of a run started from the batch screen, because that hash is taken + from the text of the recipe. - **The line before the second start says what was checked, not what was guessed.** When the window's first attempt gives no window and the program starts again with the software renderer shipped beside it (Windows), the @@ -175,6 +187,29 @@ because it turns other people's test suites red. ### Added +- **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 + bytes for every format that has a legal empty form. The whole set is 26 + files and 32 214 B, so it checks twenty-four paths through your reader for + the price of thirty-two kilobytes. Run it with `tfg generate --preset + empty-and-minimal`, or pick it on the Presets screen. + + The set comes in two groups, because two different answers are honest. Every + file in `minimal` is valid, so it expects `accept` - those are the positive + control, and if they are turned away the refusals in any other set mean + nothing. Every file in `empty` is legal and nought bytes long, so it expects + `unspecified` with the reason `size_zero`: whether an empty file should be + kept or turned away is your policy, and the manifest does not invent it. + + `--formats` narrows the set, written as a list with commas - `--formats + png,jpg,gif` for an image pipeline. It takes `all` on its own for every + format. Two things to expect from a run: several lines about files too small + to carry the label this tool writes into them, which is the tool saying so + rather than going quiet, and, for a set built only from formats that cannot + be empty, a line saying it has no empty files and naming the formats that + can. + - **A recipe can build on a preset.** Two keys the recipe reader used to refuse as not built yet now work: `extends: preset:` names the preset and `with:` fills its parameters, written the way the flags take them diff --git a/internal/cli/presetcmd.go b/internal/cli/presetcmd.go index 6dd1699c..da872668 100644 --- a/internal/cli/presetcmd.go +++ b/internal/cli/presetcmd.go @@ -228,9 +228,10 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) { fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) } - fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", + fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, %s %s\n", core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), - core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) + core.ExactBytes(b.Bytes), + core.Noun(len(b.Formats), "format", "formats"), strings.Join(b.Formats, ", ")) for _, note := range e.Notes() { fmt.Fprintf(out, "\nnote: %s\n", note) } diff --git a/internal/core/humanise.go b/internal/core/humanise.go index f1857701..8e38be1b 100644 --- a/internal/core/humanise.go +++ b/internal/core/humanise.go @@ -126,6 +126,23 @@ func Count(n int, one, many string) string { return fmt.Sprintf("%d %s", n, many) } +// Noun is the word alone in the right number, for a line that NAMES the things +// instead of counting them: "format pdf", "formats jpg, png". +// +// Count above it would say the number twice - "24 formats avif, bmp, ..." - and +// a word written flat says it wrongly. "tfg preset show" printed "format avif, +// bmp, csv, ..." on 2026-09-22, the day the first preset covering more than one +// format arrived, because the line had only ever seen a single value. +// +// The same warning as Count: nothing in the sentence may agree with the number, +// because this has no number in it to agree with. +func Noun(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + // Roughly keeps an estimate at the precision it deserves. Seconds on a two // minute estimate are noise that changes every redraw. func Roughly(d time.Duration) string { diff --git a/internal/guard/actionbarheight_test.go b/internal/guard/actionbarheight_test.go index 6350ed18..4b799164 100644 --- a/internal/guard/actionbarheight_test.go +++ b/internal/guard/actionbarheight_test.go @@ -169,6 +169,13 @@ func TestTheFormDoesNotMoveWhenARunStarts(t *testing.T) { func TestWhatARunSaysComesBeforeWhatSettlingSaid(t *testing.T) { content, w, host := screenInAWindowWithHost(t, text.TabPresets()) + // size-boundaries by name, because this guard needs a preset whose default + // is a number about SOMEBODY ELSE'S system - that is what produces a note + // at all. The screen opens on the first preset in order, which moved the + // day a preset sorting earlier arrived, and that one invents nothing and so + // says nothing. + choosePreset(t, content, "size-boundaries") + // Nothing is filled in. A note is what the run says about a value nobody // gave it, so leaving the settings alone is what produces one at all. press(t, content, text.ButtonPreview()) diff --git a/internal/guard/boxwidth_test.go b/internal/guard/boxwidth_test.go index 34e16d3d..91f08d8a 100644 --- a/internal/guard/boxwidth_test.go +++ b/internal/guard/boxwidth_test.go @@ -414,6 +414,11 @@ func TestOnlyAPathTakesTheWholeRow(t *testing.T) { {text.TabRecipe(), text.SettingLabel("password")}, } { screen := selectTab(t, host.content, named.tab) + if named.tab == text.TabPresets() { + // The box measured here belongs to size-boundaries, so the preset + // is named rather than left to whichever one the screen opens with. + choosePreset(t, screen, "size-boundaries") + } layOut() control := controlUnder(screen, named.label) if control == nil { diff --git a/internal/guard/bytecount_test.go b/internal/guard/bytecount_test.go index 0997f5fb..57b9dfe0 100644 --- a/internal/guard/bytecount_test.go +++ b/internal/guard/bytecount_test.go @@ -83,7 +83,17 @@ func TestOnlyABoxHoldingASizeCarriesACount(t *testing.T) { func byteCountBeside(t *testing.T, o fyne.CanvasObject, label string) *parts.ByteCount { t.Helper() - count := byteCountIn(fieldBox(o, label)) + // The box is looked for first, and that is not tidiness. fieldBox answers + // with a typed nil when nothing is labelled that way, which is not nil as + // an interface - so it walked into the tree walker and took the whole test + // binary down with a nil dereference on 2026-09-22, in a panic naming + // whichever test happened to be running. A guard that cannot find its box + // has to say so in a sentence. + box := fieldBox(o, label) + if box == nil { + t.Fatalf("no field is labelled %q on this screen, so nothing beside it can be counted", label) + } + count := byteCountIn(box) if count == nil { t.Fatalf("there is no count of bytes beside %q", label) } @@ -137,6 +147,9 @@ func TestADeclaredSizeSaysWhatItComesToOnEveryScreenThatDrawsOne(t *testing.T) { t.Run("a preset parameter", func(t *testing.T) { _, content := presetScreen(t) + // The parameter measured here is a size, and size-boundaries is the + // preset that declares one. + choosePreset(t, content, "size-boundaries") label := text.SettingLabel("limit") count := byteCountBeside(t, content, label) diff --git a/internal/guard/compose_test.go b/internal/guard/compose_test.go index a6a9c35c..3a3135b7 100644 --- a/internal/guard/compose_test.go +++ b/internal/guard/compose_test.go @@ -32,6 +32,80 @@ import ( // legal has to arrive as itself, byte for byte, because a value quietly altered // on the way in is untouchable rule 6 - silence - with the tool doing the // altering. +// A composed recipe looks like one a person would have written by hand. +// +// It matters because of what the header of an ejected recipe promises: "edit +// it, commit it, it is an ordinary recipe from here on". Somebody then pastes +// a target into it, copied from docs/RECIPE.md - and every example there is +// indented under its key and writes its numbers bare. A document composed with +// the marshaller's flat default took that paste and stopped parsing, with the +// error pointing at the line the person had just added. Caught on 2026-09-22 by +// TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended, +// which appends a target the way a person would. +// +// The last two cases are the ones that keep this honest rather than merely +// tidy. A name is text even when it is made of digits, so a file called 123 +// must not become the number one hundred and twenty three. +// +// Both spellings are here for a measured reason. "007" alone looked like the +// same assertion and was not: bareNumber refuses a leading zero on its own +// account, so a mutation applying it to the name left "007" untouched and this +// guard stayed green - NOT CAUGHT on 2026-09-22, an entry that found its +// pattern, compiled, and proved nothing. "123" is the spelling that actually +// moves, and "007" stays beside it because the two failures are different: one +// is the tidying reaching a field it should not, the other is the tidying +// keeping a spelling it should not. +func TestAComposedRecipeIsWrittenTheWayAPersonWritesOne(t *testing.T) { + source, err := recipe.Compose(recipe.Document{ + Targets: []recipe.TargetDraft{ + {ID: "first", Format: "txt", Count: "2", Size: "1024", Name: "007", Group: "g"}, + {ID: "second", Format: "txt", Count: "1", Size: "2mb", Name: "later.txt"}, + {ID: "third", Format: "txt", Count: "1", Size: "512", Name: "123"}, + }, + }) + if err != nil { + t.Fatalf("composing refused a document with nothing wrong in it: %v", err) + } + got := string(source) + + for _, want := range []string{ + // Indented under its key, which is what every recipe in the documents + // looks like and what a pasted target has to line up with. + "targets:\n - id: first\n", + // Bare, because a person writing this by hand writes count: 2. + "count: 2\n", + "size: 1024\n", + // Text, because it is text: a size may be written 2mb and a name may be + // made of digits. + "size: 2mb\n", + `name: "007"`, + `name: "123"`, + } { + if !strings.Contains(got, want) { + t.Errorf("a composed recipe does not hold %q.\nIt reads:\n%s", want, got) + } + } + + // And it still parses, which is the point of all of the above. + if _, err := recipe.Parse(source, "composed.yaml"); err != nil { + t.Errorf("a composed recipe does not read back: %v\n%s", err, got) + } + + // The names survived the round trip as text rather than as numbers. + back, err := recipe.Parse(source, "composed.yaml") + if err != nil { + return + } + for i, want := range []string{"007", "later.txt", "123"} { + if i >= len(back.Targets) { + t.Fatalf("the recipe came back with %d targets and three went in", len(back.Targets)) + } + if got := back.Targets[i].Name; got != want { + t.Errorf("a name came back as %q rather than %q - a number took a file's name", got, want) + } + } +} + func TestARecipeComposedFromTypedTextSurvivesAnythingTypedIntoIt(t *testing.T) { hostile := []struct { name string diff --git a/internal/guard/extends_test.go b/internal/guard/extends_test.go index c383bfb2..9fa92ab0 100644 --- a/internal/guard/extends_test.go +++ b/internal/guard/extends_test.go @@ -191,6 +191,13 @@ func TestARecipeBuiltOnAPresetFromTheWindowGivesTheBytesTheFileGives(t *testing. t.Fatal("there is no switch to build on a preset on the batch screen") } switchOn.SetChecked(true) + // Named rather than left to the section's opening choice, which is the + // first preset in order and moved the day one sorting earlier arrived. + // + // Reached through the tree rather than through the registry, because the + // control registered at "extends" is the menu inside its width wrapper. + // The fields are taken AFTER the choice, because choosing rebuilds them. + chooserUnder(t, content, text.FieldBasePreset()).SetSelected("size-boundaries") fields := screen.Fields() setBox(t, fields, recipe.KeyWith+".limit", "4mb") chooserIn(t, fields, recipe.KeyWith+".format").SetSelected("txt") @@ -258,6 +265,8 @@ func TestTheBatchScreenCanRunAPresetsSetAlone(t *testing.T) { t.Error("with no batch left, the keyboard does not start at the switch") } + // Named rather than left to the section's opening choice. See above. + chooserUnder(t, content, text.FieldBasePreset()).SetSelected("size-boundaries") fields := screen.Fields() setBox(t, fields, recipe.KeyWith+".limit", "4mb") chooserIn(t, fields, recipe.KeyWith+".format").SetSelected("txt") diff --git a/internal/guard/fieldmarking_test.go b/internal/guard/fieldmarking_test.go index 21c13a09..e30b2218 100644 --- a/internal/guard/fieldmarking_test.go +++ b/internal/guard/fieldmarking_test.go @@ -95,6 +95,7 @@ func TestTheMarkGoesWhenTheValueIsFixed(t *testing.T) { // left undone the last time a refusal moved. func TestTheBoxARefusalIsAboutIsMarkedOnThePresetScreenToo(t *testing.T) { _, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.SettingLabel("limit"), "512") press(t, content, "Preview") @@ -167,6 +168,7 @@ func TestTheMenuTheKeyboardIsInDrawsALine(t *testing.T) { // A refusal outranks the keyboard, because one of the two stops the run. func TestARefusedBoxStaysRedWhileTheKeyboardIsInIt(t *testing.T) { _, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.SettingLabel("limit"), "512") press(t, content, "Preview") diff --git a/internal/guard/filekind_test.go b/internal/guard/filekind_test.go index ca87537b..353778b1 100644 --- a/internal/guard/filekind_test.go +++ b/internal/guard/filekind_test.go @@ -35,6 +35,7 @@ func TestThePresetScreenCanBuildTheSetInAnyFormat(t *testing.T) { dir := t.TempDir() host, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.FieldOutputDir(), dir) fill(t, content, text.SettingLabel("limit"), "2mb") choose(t, content, text.SettingLabel("format"), "png") @@ -88,6 +89,7 @@ func TestChoosingTheFormatGivesTheSameSetOnBothSurfaces(t *testing.T) { } host, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.FieldOutputDir(), fromWindow) fill(t, content, text.SettingLabel("limit"), "2mb") choose(t, content, text.SettingLabel("format"), "png") @@ -157,6 +159,9 @@ func TestThePreviewSaysWhatKindOfFilesItWouldWrite(t *testing.T) { } presetHost, presets := presetScreen(t) + // size-boundaries by name: it is the preset that reads the global format + // flag, so it is the one whose screen carries a format menu at all. + choosePreset(t, presets, "size-boundaries") fill(t, presets, text.FieldOutputDir(), t.TempDir()) choose(t, presets, text.SettingLabel("format"), "wav") press(t, presets, "Preview") diff --git a/internal/guard/menudefault_test.go b/internal/guard/menudefault_test.go index dc6a71d0..20a57504 100644 --- a/internal/guard/menudefault_test.go +++ b/internal/guard/menudefault_test.go @@ -159,7 +159,16 @@ func TestEveryMenuOfferingEveryFormatDrawsTheKindPictures(t *testing.T) { // hide behind a screen with two, which is the shape of the defect itself - // two menus were missed for twenty days while a third had the pictures. for _, tab := range []string{text.TabOneTarget(), text.TabRecipe(), text.TabPresets()} { - if n := look(tab, tabNamed(t, host.content, tab)); n != 1 { + root := tabNamed(t, host.content, tab) + if tab == text.TabPresets() { + // The menu counted here belongs to the preset that reads the global + // format flag, and only size-boundaries does. The screen opens on + // the first preset in order, which moved the day one sorting + // earlier arrived - and a preset declaring no menu would leave this + // guard counting nothing on a screen that has one. + choosePreset(t, root, "size-boundaries") + } + if n := look(tab, root); n != 1 { t.Errorf("the %s screen has %d menu(s) offering every format and this guard expects 1", tab, n) } } diff --git a/internal/guard/minimalset_test.go b/internal/guard/minimalset_test.go new file mode 100644 index 00000000..832bdfbc --- /dev/null +++ b/internal/guard/minimalset_test.go @@ -0,0 +1,257 @@ +package guard + +import ( + "bytes" + "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" +) + +// The empty-and-minimal set asks every format for the smallest size it takes, +// and carries a separate empty file for every format that has one. +// +// The set is the whole product here, so this asserts what is IN it rather than +// that it expanded. Three claims, and each one is a way the set could go quietly +// wrong: +// +// Every file sits exactly on its format's floor. A size one byte above would +// still run, still verify and still look like a set about minimums - and would +// stop being the thing the preset promises. +// +// A format whose floor is nought bytes gets TWO files: one of a byte for the +// positive control, one of nothing for the half nobody can promise an answer +// for. One entry each would mean either dropping those formats out of the +// control or giving an empty file an expectation nobody can back, which is +// untouchable rule 5. +// +// And the count of empty files equals the count of formats that can be empty, +// asserted from the registry rather than from the number two. Two is today's +// answer, md and txt, and a third format reaching nought would otherwise leave +// this guard green while the set silently changed shape. +func TestTheMinimalSetSitsOnEveryFormatsFloor(t *testing.T) { + expanded, err := preset.Expand("empty-and-minimal", preset.Args{}) + if err != nil { + t.Fatalf("the preset refused its own defaults: %v", err) + } + doc, err := recipe.Parse(expanded.Source, "empty-and-minimal.yaml") + if err != nil { + t.Fatalf("the preset wrote a recipe this build cannot read: %v\n%s", err, expanded.Source) + } + + canBeEmpty := map[string]bool{} + for _, id := range format.IDs() { + desc, err := format.Get(id) + if err != nil { + t.Fatalf("the registry lists %s and then does not have it: %v", id, err) + } + if desc.SmallestAccepted(format.Request{Label: true}) == 0 { + canBeEmpty[id] = true + } + } + if len(canBeEmpty) == 0 { + t.Fatal("no format in this build reaches nought bytes, so the empty half of this set " + + "cannot exist and this guard is asserting nothing") + } + + sizes := map[string]int64{} + empties := map[string]bool{} + for _, target := range doc.Targets { + desc, err := format.Get(target.Format) + if err != nil { + t.Fatalf("the set holds a target in format %q, which this build does not have", target.Format) + } + floor := desc.SmallestAccepted(format.Request{Label: true}) + + // The settled sizes rather than the text, because the text is what the + // recipe wrote and these are what the run will produce. One file per + // target here, so a target holding several sizes is itself a failure. + if len(target.Sizes) != 1 { + t.Errorf("the target %s holds %d sizes and every file of this set is one file", + target.ID, len(target.Sizes)) + continue + } + size := target.Sizes[0] + + if size == 0 { + empties[target.Format] = true + if !canBeEmpty[target.Format] { + t.Errorf("the set holds an empty %s and %s cannot be empty - its floor is %d B", + target.Format, target.Format, floor) + } + if target.Expected != "unspecified" || target.ExpectedReason != "size_zero" { + t.Errorf("the empty %s expects %q for %q - an empty file is legal and what to do "+ + "with it is the application's policy, so it has to be unspecified with size_zero", + target.Format, target.Expected, target.ExpectedReason) + } + continue + } + + if target.Expected != "accept" { + t.Errorf("the smallest valid %s expects %q - it is a valid file, so it is the positive "+ + "control and has to be accept", target.Format, target.Expected) + } + want := floor + if want == 0 { + // A format whose floor is nought still needs a file with something + // in it, or it drops out of the positive control. + want = 1 + } + if size != want { + t.Errorf("the minimal %s is %d B and the smallest this build takes is %d B", + target.Format, size, want) + } + sizes[target.Format] = size + } + + if len(sizes) != len(format.IDs()) { + t.Errorf("the set covers %d formats and this build has %d - every format is one path "+ + "through somebody's reader", len(sizes), len(format.IDs())) + } + if len(empties) != len(canBeEmpty) { + t.Errorf("the set holds %d empty files and %d formats in this build can be empty", + len(empties), len(canBeEmpty)) + } + // The targets themselves, because the two counts above are counts of MAPS + // keyed by format - a second minimal png would overwrite the first and + // leave both of them reading exactly as they do now. Named by CodeRabbit on + // 2026-09-22, and it is the shape this project calls a guard that stopped + // reaching the state it guards. + if want := len(format.IDs()) + len(canBeEmpty); len(doc.Targets) != want { + t.Errorf("the set holds %d targets and %d were expected - one per format, plus one more "+ + "for every format that can be empty", len(doc.Targets), want) + } +} + +// The same formats in two orders build the same set. +// +// The order somebody types is not part of what they asked for. It used to be: +// "--formats png,zip" and "--formats zip,png" produced different recipe text, +// so the same selection carried two different recipe_hash values into two +// manifests and listed the files the other way round. Measured on 2026-09-22 - +// f76a3883e against 073157029 - after a comment in this package had claimed +// registry order for weeks without anything walking the registry. +// +// The bytes of the files never moved, because a seed comes from the id of a +// target rather than from its place in the list. That is what made this quiet: +// every file was right and only the record of them disagreed. +func TestTheMinimalSetIsTheSameWhateverOrderTheFormatsAreNamedIn(t *testing.T) { + // Two formats far apart in the registry, so a walk that kept the typing + // cannot pass by accident. + first, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "png,bmp,zip"}) + if err != nil { + t.Fatalf("the preset refused three formats: %v", err) + } + second, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "zip,png,bmp"}) + if err != nil { + t.Fatalf("the preset refused the same three in another order: %v", err) + } + if !bytes.Equal(first.Source, second.Source) { + t.Errorf("the same formats in two orders built two recipes.\n--- png,bmp,zip ---\n%s\n--- zip,png,bmp ---\n%s", + first.Source, second.Source) + } + + // And the order they come out in is the registry's, rather than merely + // being the same both times - two runs agreeing on a wrong order would + // satisfy the check above and still put bmp after zip. + doc, err := recipe.Parse(first.Source, "empty-and-minimal.yaml") + if err != nil { + t.Fatalf("the preset wrote a recipe this build cannot read: %v", err) + } + seen := make([]string, 0, len(doc.Targets)) + for _, target := range doc.Targets { + seen = append(seen, target.Format) + } + if want := []string{"bmp", "png", "zip"}; strings.Join(seen, ",") != strings.Join(want, ",") { + t.Errorf("the set is laid out as %v and the registry names them %v", seen, want) + } +} + +// A set that came out with only one of its halves says so. +// +// Untouchable rule 6. "--formats zip" is a sensible thing to ask for and no +// archive has a legal empty form, so the set is all minimal and no empty - and a +// preset named after both halves that ships one quietly is a promise it did not +// keep. +// +// The second half of this guard is the one that stops it from passing for the +// wrong reason: a preset that said this on EVERY run would also pass the first +// half, and would be noise on the run that has both halves. +func TestASetWithNoEmptyHalfSaysSoAndOneWithBothStaysQuiet(t *testing.T) { + quiet, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "txt,zip"}) + if err != nil { + t.Fatalf("the preset refused a set with both halves: %v", err) + } + for _, note := range quiet.Notes() { + if strings.Contains(note, "no empty files") { + t.Errorf("a set that HAS empty files still says %q", note) + } + } + + spoken, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "zip"}) + if err != nil { + t.Fatalf("the preset refused a set of one format: %v", err) + } + said := strings.Join(spoken.Notes(), "\n") + if !strings.Contains(said, "no empty files") { + t.Errorf("a set with no empty half says nothing about it. It said: %q", said) + } + // The sentence names the formats that CAN be empty, and it has to name them + // from the registry - a list written into the sentence would go stale green + // the day a third format reached nought bytes. + for _, id := range format.IDs() { + desc, err := format.Get(id) + if err != nil || desc.SmallestAccepted(format.Request{Label: true}) != 0 { + continue + } + if !strings.Contains(said, id) { + t.Errorf("%s reaches nought bytes and the note does not name it: %q", id, said) + } + } +} + +// A list parameter refuses two spellings of one item. +// +// This is the 2026-08-05 collision one layer up, and it came back on 2026-09-22 +// in the shared list parser: "PNG,png" passed a duplicate check made on what was +// typed, became two targets of one id, and surfaced as "target id minimal_png is +// used twice" - a refusal about an id nobody had written, pointing at the recipe +// rather than at the value. +// +// Asserted through the message rather than only through the failure, because +// both spellings failing is not the point. WHICH refusal arrives is the point. +func TestAListParameterRefusesTwoSpellingsOfOneItem(t *testing.T) { + _, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "PNG,png"}) + if err == nil { + t.Fatal("the preset built a set holding one format twice") + } + if !strings.Contains(err.Error(), "the same format") { + t.Errorf("the refusal is %q.\nIt has to be about the repeated value, not about the "+ + "recipe the value produced", err.Error()) + } +} + +// No format is called by the word that means all of them. +// +// The formats parameter takes "all" as a keyword, so a format registered under +// that id would be unreachable through the parameter that exists to reach it - +// silently, because the keyword would simply win. Nothing else in the tree would +// notice. +// +// Cheap, and it is the kind of collision that turns up years later with no clue +// attached. See internal/preset/emptyandminimal.go, which names this guard. +func TestNoFormatIsCalledByTheWordThatMeansAllOfThem(t *testing.T) { + ids := format.IDs() + if len(ids) == 0 { + t.Fatal("no format is registered, so this guard checked nothing") + } + for _, id := range ids { + if strings.EqualFold(id, "all") { + t.Errorf("a format is registered as %q, which is the word the empty-and-minimal "+ + "formats parameter uses for every format - one of the two has to be renamed", id) + } + } +} diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index acddc7eb..3a5795f2 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -59,6 +59,12 @@ var reachableFromTheWindow = []string{ "preset:size-boundaries", "preset:size-boundaries.limit", "preset:size-boundaries.spread", + // The second preset and its one parameter. Pressed on the screen by + // TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds, which types + // a list of formats into the box and compares the run with the one the + // command line makes from the same list. + "preset:empty-and-minimal", + "preset:empty-and-minimal.formats", // The global flag this preset supplies a value for, drawn from // preset.Global and pressed by TestThePresetScreenCanBuildTheSetInAnyFormat, // which runs the set and reads the bytes back rather than looking at the diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go index bb7b6733..8bf2415a 100644 --- a/internal/guard/presetwindow_test.go +++ b/internal/guard/presetwindow_test.go @@ -38,6 +38,108 @@ import ( // It returns that tab rather than the window: since 2026-08-11 the window holds // every screen at once, and both work screens have a field called "output // directory", so a lookup across the whole thing finds whichever comes first. +// The preset screen builds the minimal set the command line builds, with the +// formats typed into the box. +// +// This is what the two parity entries for empty-and-minimal stand on. The +// screen draws the parameter from the same declaration the flag comes from, so +// "it appears on both surfaces" is nearly free - and nearly free is the problem. +// What is NOT free is that a list typed into a box reaches the engine as the +// same list the flag carries, in the same order, producing the same bytes. +// +// Three formats rather than the default, because the default is the one value +// that cannot tell a box that works from a box whose contents are dropped: with +// "all" standing in either way, a screen that sent nothing would pass. +func TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds(t *testing.T) { + root := t.TempDir() + fromCLI := filepath.Join(root, "cli") + fromWindow := filepath.Join(root, "window") + + const chosen = "png,txt,zip" + + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{ + "generate", "--preset", "empty-and-minimal", "--formats", chosen, "--out", fromCLI, + }, &out, &errOut); code != cli.ExitOK { + t.Fatalf("the command line refused the preset: exit %d\n%s", code, errOut.String()) + } + + host, content := presetScreen(t) + choosePreset(t, content, "empty-and-minimal") + fill(t, content, text.FieldOutputDir(), fromWindow) + fill(t, content, text.SettingLabel("formats"), chosen) + press(t, content, "Generate") + waitForManifest(t, host, fromWindow) + join(host) + + cliNames, windowNames := namesIn(t, fromCLI), namesIn(t, fromWindow) + if strings.Join(cliNames, " ") != strings.Join(windowNames, " ") { + t.Fatalf("the two surfaces produced different files.\n command line: %v\n window: %v", + cliNames, windowNames) + } + + // png, txt and zip are three minimal files, and txt reaches nought bytes so + // it brings an empty one as well. Asserted rather than logged, because an + // equality between two empty sets proves nothing. + const wanted = 4 + if len(cliNames) != wanted+1 { + t.Fatalf("the preset produced %d thing(s) and %d files plus a manifest was expected: %v", + len(cliNames), wanted, cliNames) + } + + compared := 0 + for _, name := range cliNames { + if name == "manifest.json" { + continue + } + compared++ + a, err := os.ReadFile(filepath.Join(fromCLI, name)) + if err != nil { + t.Fatalf("reading %s from the command line run: %v", name, err) + } + b, err := os.ReadFile(filepath.Join(fromWindow, name)) + if err != nil { + t.Fatalf("reading %s from the window run: %v", name, err) + } + if !bytes.Equal(a, b) { + t.Errorf("%s differs between the surfaces: %d B from the command line, %d B from the window", + name, len(a), len(b)) + } + } + if compared != wanted { + t.Fatalf("%d files were compared and %d were expected", compared, wanted) + } + + // The record has to agree too, or two runs that produced the same bytes + // would still be described differently to whoever reads the manifest. + for _, want := range []string{`"id": "empty-and-minimal"`, `"formats": "` + chosen + `"`} { + if !strings.Contains(manifestText(t, fromWindow), want) { + t.Errorf("the window's manifest does not carry %s", want) + } + if !strings.Contains(manifestText(t, fromCLI), want) { + t.Errorf("the command line's manifest does not carry %s", want) + } + } +} + +// choosePreset picks one preset on the screen by name. +// +// Explicitly, rather than leaning on the choice the screen opens with. That +// choice is the first id in order, so it moved the day a preset sorting before +// size-boundaries arrived - and six guards went red at once, every one of them +// reporting that a field was not on the screen rather than that they had been +// looking at a different preset. A guard that names what it is about cannot be +// moved by the next preset to be written. +func choosePreset(t *testing.T, content fyne.CanvasObject, id string) { + t.Helper() + control := controlUnder(content, text.FieldPreset()) + picker, ok := control.(*parts.Chooser) + if !ok { + t.Fatalf("the preset field is %T rather than a list to choose from", control) + } + picker.SetSelected(id) +} + func presetScreen(t *testing.T) (*fakeHost, fyne.CanvasObject) { t.Helper() host := newFakeHost(t) @@ -142,6 +244,7 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) { } host, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.FieldOutputDir(), fromWindow) fill(t, content, text.SettingLabel("limit"), "2mb") press(t, content, "Generate") @@ -228,6 +331,7 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) { func TestThePresetScreenSaysWhichNumbersWereOurs(t *testing.T) { dir := t.TempDir() host, content := presetScreen(t) + choosePreset(t, content, "size-boundaries") fill(t, content, text.FieldOutputDir(), dir) // limit left at its declared default, spread stated by hand. diff --git a/internal/guard/refusalplacement_test.go b/internal/guard/refusalplacement_test.go index deb30be7..79db7da6 100644 --- a/internal/guard/refusalplacement_test.go +++ b/internal/guard/refusalplacement_test.go @@ -82,6 +82,7 @@ func TestARefusalAboutTheSizeAppearsUnderIt(t *testing.T) { // generate screen's refusals were moved on 2026-08-11. func TestARefusalAboutAPresetSettingAppearsUnderIt(t *testing.T) { content, w := screenInAWindow(t, text.TabPresets()) + choosePreset(t, content, "size-boundaries") fill(t, content, text.SettingLabel("limit"), "512") press(t, content, "Preview") diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go index 182fb1f2..85580e15 100644 --- a/internal/guard/regressiontable_test.go +++ b/internal/guard/regressiontable_test.go @@ -48,7 +48,6 @@ import ( // would quietly become the place drift hides. var notYetJustified = []string{ "actionbar_test.go", - "compose_test.go", "darkmenus_test.go", "doccomments_test.go", "dropdown_test.go", diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index 3ba9be39..eb39e780 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -419,7 +419,11 @@ func screenScenes() []screenScene { flipSwitch(t, s.canvas, s.tab, text.FieldLabel()) }}, {name: "preset", tab: text.TabPresets()}, + // The refusal belongs to size-boundaries, so the preset is named rather + // than left to whichever one the screen opens with - that is the first + // id in order and it moved the day a preset sorting earlier arrived. {name: "preset-refused", tab: text.TabPresets(), set: func(t *testing.T, s scene) { + menuUnder(t, s.tab, text.FieldPreset()).SetSelected("size-boundaries") fillField(t, s.tab, text.SettingLabel("limit"), "512") pressNamed(t, s.tab, text.ButtonPreview()) }}, @@ -436,9 +440,15 @@ func screenScenes() []screenScene { // The list a preset DECLARES, drawn by the same machinery from the same // kind of declaration as a format's own settings, and landing somewhere // else on the form. - {name: "preset-menu-setting", tab: text.TabPresets(), after: func(t *testing.T, s scene) { - menuUnder(t, s.tab, text.SettingLabel("format")).Tapped(&fyne.PointEvent{}) - }}, + {name: "preset-menu-setting", tab: text.TabPresets(), + set: func(t *testing.T, s scene) { + // The only preset that reads a global flag, so it is the only + // one with a menu among its settings. + menuUnder(t, s.tab, text.FieldPreset()).SetSelected("size-boundaries") + }, + after: func(t *testing.T, s scene) { + menuUnder(t, s.tab, text.SettingLabel("format")).Tapped(&fyne.PointEvent{}) + }}, // The recipe screen, which arrived on 2026-08-18. It has states neither // of the others can be put into, and every one of them is here because a diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go index c61fa8fc..01e5c42a 100644 --- a/internal/guard/settingslot_test.go +++ b/internal/guard/settingslot_test.go @@ -185,7 +185,7 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T) "entries": "an", "bit_depth": "a", "sample_rate": "a", "channels": "a", "paragraphs": "a", "rows": "a", "columns": "a", "slides": "a", "depth": "a", "colours": "a", "records": "a", "lines": "a", - "damage": "a", "bytes": "a", + "damage": "a", "bytes": "a", "formats": "a", // The batch screen's base section: the switch, and the two recipe // keys behind it. The parameters under with. arrive as the // preset's own names, which are above. @@ -198,6 +198,7 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T) "Limit to test": "a", "One size": "a", "A range": "a", "Rule being tested": "a", "Manifest file name": "a", "Preset": "a", "Limit": "a", "Spread": "a", "Width": "a", "Height": "a", "Quality": "a", + "Formats": "a", "Label in each file": "a", "Start from a preset": "a", "Preset to build on": "a", } diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 4a571f32..10775fbc 100644 Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml index 40a0cb18..584c4706 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -72,18 +72,18 @@ - - - + + + - + size-boundaries - + - + diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 6e345f1f..1c2fa78f 100644 Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml index 5816ab03..b3c6c87f 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -56,10 +56,10 @@ - - - - + + + + The question @@ -72,28 +72,28 @@ - - - + + + - - size-boundaries + + empty-and-minimal - + - + - + - Is a size limit enforced exactly where it is declared? + Does a file that is valid and as small as the format allows get through? @@ -107,13 +107,13 @@ - + - off by one errors at the limit + a valid file turned away for being too small, where the check counts bytes instead of reading them @@ -125,7 +125,7 @@ - MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass + an empty file that brings the reader down rather than being reported @@ -137,69 +137,41 @@ - a limit enforced in the browser and not on the server + a picture one pixel wide that divides by zero on the way to a thumbnail - - - - - - - - - - Settings - - - - - Limit - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - + + + + + + storage that reads nought bytes as a failed upload and keeps retrying + - + + + + + + + + + Settings + + - Spread - + Formats + @@ -213,7 +185,7 @@ - 1B,1kb,1mb + all @@ -226,38 +198,11 @@ - - - - Format - - - - - - - - - - - - - pdf - - - - - - - - - - - + @@ -359,11 +304,12 @@ - - - - - 7 files · 70.0 MB (73 400 320 B) · pdf · will go to /tfg/out + + + + + 26 files · 31.5 KB (32 214 B) · avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, + txt, wav, webp, xlsx, xml, zip · will go to /tfg/out @@ -394,23 +340,35 @@ - - - - - - - - - - - - - - - size-boundaries + + + + + + + + + + + + + + + empty-and-minimal + + + + + + size-boundaries + + + + + + diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index 489d0021..cc9a76e2 100644 Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml index 84d8893c..c36e282d 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -72,18 +72,18 @@ - - - + + + - + size-boundaries - + - + diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 5a60d654..2d237ea3 100644 Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml index 787a1714..8b1b7a5a 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -56,10 +56,10 @@ - - - - + + + + The question @@ -72,28 +72,28 @@ - - - + + + - - size-boundaries + + empty-and-minimal - + - + - + - Is a size limit enforced exactly where it is declared? + Does a file that is valid and as small as the format allows get through? @@ -107,13 +107,13 @@ - + - off by one errors at the limit + a valid file turned away for being too small, where the check counts bytes instead of reading them @@ -125,7 +125,7 @@ - MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass + an empty file that brings the reader down rather than being reported @@ -137,69 +137,41 @@ - a limit enforced in the browser and not on the server + a picture one pixel wide that divides by zero on the way to a thumbnail - - - - - - - - - - Settings - - - - - Limit - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - + + + + + + storage that reads nought bytes as a failed upload and keeps retrying + - + + + + + + + + + Settings + + - Spread - + Formats + @@ -213,7 +185,7 @@ - 1B,1kb,1mb + all @@ -226,38 +198,11 @@ - - - - Format - - - - - - - - - - - - - pdf - - - - - - - - - - - + @@ -359,11 +304,12 @@ - - - - - 7 files · 70.0 MB (73 400 320 B) · pdf · will go to /tfg/out + + + + + 26 files · 31.5 KB (32 214 B) · avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, + txt, wav, webp, xlsx, xml, zip · will go to /tfg/out diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index 77498223..0abb3d32 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index a643567d..a635275d 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -32,8 +32,8 @@ - - + + @@ -56,11 +56,11 @@ - - - - - + + + + + Build on a preset @@ -101,67 +101,27 @@ - - - + + + - - size-boundaries + + empty-and-minimal - + - + - - - - Limit - - - - - - - - - - - - - - - 10mb - - - - - - - - - - - - - - - - - - - - - - + - Spread - + Formats + @@ -175,7 +135,7 @@ - 1B,1kb,1mb + all @@ -188,38 +148,11 @@ - - - - Format - - - - - - - - - - - - - pdf - - - - - - - - - - - + @@ -490,7 +423,7 @@ - + @@ -622,8 +555,8 @@ - - + + diff --git a/internal/preset/build.go b/internal/preset/build.go new file mode 100644 index 00000000..420ed9b3 --- /dev/null +++ b/internal/preset/build.go @@ -0,0 +1,173 @@ +package preset + +import ( + "fmt" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// The machinery every preset shares, so that a preset file holds its question +// and its plan and nothing else. +// +// It exists because of what the second preset showed. size-boundaries carried +// its own list parser, its own character check and its own YAML writer, and +// writing empty-and-minimal beside it would have copied all three - with the +// fourth and fifth copies already named in the queue, because upload-validation +// takes two lists and text-encoding takes a third. A problem that comes back is +// a missing primitive rather than a missing tidy-up. + +// commaList is the one parser behind every list a preset takes. +// +// Lists are written as one scalar with commas - "1B,1kb,1mb", "jpg,png,pdf" - +// and that is a decision rather than a habit: a preset parameter is a +// map[string]string all the way through, so the same value reads the same way +// as a flag, under "with:" in a recipe and in a field on a screen. A second +// spelling for one value is where eject and extends would drift apart. See +// docs/EXTENDS-WITH-2026-09-22.md section 2.2 e. +type commaList struct { + // preset and param name the setting a refusal is about, so the message + // carries the name both surfaces use - the flag without its dashes and the + // label on the field. + preset, param string + // empty is the reason for a list that named nothing at all. + empty string + // check judges one item and answers with why it is not allowed, or "" when + // it is. It receives the item trimmed, never blank. + check func(item string) string + // same is what makes two items one, for the duplicate that would otherwise + // reach the recipe as two targets of one id. Nil compares the items + // themselves. size-boundaries compares byte counts instead, so that 1024 + // and 1kb collide - found by fuzzing on 2026-08-05, where the collision + // surfaced as a recipe the parser refused, complaining about target ids + // nobody typed. + same func(item string) string + // keep is what to store when an item is accepted. Nil keeps the item as it + // was trimmed. + keep func(item string) string + // duplicate words the refusal for an item the list is already holding, + // given the item it repeats. + // + // A field rather than one sentence written here, because the general + // sentence is worse than the one it would replace. A list of distances says + // "the same distance, and each one names one file either side of the + // limit", a list of formats says something else, and a primitive that + // flattens both into "the same item" makes the message vaguer for everyone + // in order to save a line. The text rules in CLAUDE.md ask for the name the + // reader sees, not the name the code uses. + duplicate func(first string) string +} + +// refuse is the shape a bad value in a preset parameter takes. +// +// The type the format registry raises for a value outside its declaration, +// rather than a plain error, and that is a repair rather than a preference. A +// plain error falls through the classifier to RUNTIME, so "--spread notasize" +// told CI this program had a bug instead of saying the value was wrong - +// measured on 2026-08-05, exit 1. +func (l commaList) refuse(value, reason string) error { + return &format.PropertyValueError{ + Format: l.preset, Key: l.param, Value: value, Reason: reason, + } +} + +// parse splits the value and judges every item of it. +// +// The order of the three steps is the whole of this function, and it was wrong +// once. Normalising has to happen BEFORE two items are compared, because being +// the same is a property of the value rather than of the typing: "PNG,png" +// passed a check made on what was typed, then became two identical ids and +// surfaced as "target id minimal_png is used twice" - a refusal about an id +// nobody had written. Measured 2026-09-22, and it is the same shape as the +// collision fuzzing found in the spread on 2026-08-05. +func (l commaList) parse(raw string) ([]string, error) { + var out []string + seen := map[string]string{} + for _, piece := range strings.Split(raw, ",") { + typed := strings.TrimSpace(piece) + if typed == "" { + continue + } + // Judged as it was typed, so the message quotes what the reader can see + // in their own command line. + if bad := l.check(typed); bad != "" { + return nil, l.refuse(typed, bad) + } + item := typed + if l.keep != nil { + item = l.keep(typed) + } + key := item + if l.same != nil { + key = l.same(item) + } + if first, repeated := seen[key]; repeated { + return nil, l.refuse(typed, l.duplicate(first)) + } + seen[key] = typed + out = append(out, item) + } + if len(out) == 0 { + return nil, l.refuse(raw, l.empty) + } + return out, nil +} + +// knownFormat is the check for a list whose items name formats. +// +// The registry answers rather than a list written here, which is the rule +// pulled from CLAUDE.md the hard way: a list copied by hand goes stale green. +// The refusal names what this build has, because a person who typed heic has no +// other way to find out what it does have. +func knownFormat(item string) string { + if _, err := format.Get(strings.ToLower(item)); err != nil { + return fmt.Sprintf("this build has no format called that. It has: %s", + strings.Join(format.IDs(), ", ")) + } + return "" +} + +// lower is the keep for a list of names the registry spells in lower case. +func lower(item string) string { return strings.ToLower(item) } + +// plan is the set a preset lays out, ready to be written. +// +// A preset describes targets and this turns them into source. PR5 asks for +// source rather than a structure, so that what eject prints and what a run +// consumes are the same bytes and cannot drift apart. +type plan struct { + // preset and question go in the header, so an ejected recipe says where it + // came from and what it was for. + preset, question string + targets []recipe.TargetDraft +} + +// source writes the recipe. +// +// recipe.Compose marshals with the YAML library rather than printing lines, +// and that is the whole reason this exists. The hand written writer in +// sizeboundaries.go carries a comment saying it is safe because every value is +// one the package built itself - and that comment was wrong until 2026-08-05, +// when fuzzing found "1\rB" reaching the document raw through a value the +// caller had typed. Compose owns the quoting, and it owns the shape of the +// document, so a key added to the schema is added in one place rather than in +// every preset. +// +// The header is written here rather than composed, because a comment is not +// part of the document and a marshaller has nowhere to put one. Every byte of +// it is this package's own text - an id and a question, both constants - so +// there is nothing here for a caller's typing to reach. +func (p plan) source() ([]byte, error) { + body, err := recipe.Compose(recipe.Document{Targets: p.targets}) + if err != nil { + return nil, err + } + var b strings.Builder + fmt.Fprintf(&b, "# Generated by: tfg preset eject %s\n", p.preset) + fmt.Fprintf(&b, "# %s\n", p.question) + b.WriteString("#\n") + b.WriteString("# Edit it, commit it, it is an ordinary recipe from here on.\n\n") + b.Write(body) + return []byte(b.String()), nil +} diff --git a/internal/preset/emptyandminimal.go b/internal/preset/emptyandminimal.go new file mode 100644 index 00000000..db2d11bc --- /dev/null +++ b/internal/preset/emptyandminimal.go @@ -0,0 +1,311 @@ +package preset + +import ( + "fmt" + "strconv" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +const ( + minimalID = "empty-and-minimal" + // everyFormat is what the formats parameter says when it means all of them. + // + // A word rather than the list written out, because the list is a property of + // the build and this declaration is read at init, before the format registry + // has necessarily finished filling. Global() above carries the same note for + // the same reason. It also means the default stays one short word on a + // screen instead of a hundred characters of ids. + everyFormat = "all" + + minimalGroup = "minimal" + emptyGroup = "empty" + + // minimalQuestion is announced by the preset AND written into the header of + // an ejected recipe. Named once rather than typed twice, because two copies + // of one sentence are how the recipe and the list drift apart. + minimalQuestion = "Does a file that is valid and as small as the format allows get through?" +) + +func init() { + Register(Preset{ + ID: minimalID, + Title: "Empty and minimal", + Question: minimalQuestion, + + Parameters: []format.Property{ + { + Name: "formats", Kind: format.PropertyText, + Shape: "format ids separated by commas, or all", + Default: everyFormat, + Detail: "Which formats the set is built from. Leave it at all for every format " + + "this build has, or name the ones your system accepts.", + }, + }, + + Requires: []string{"MVP"}, + Catches: []string{ + "a valid file turned away for being too small, where the check counts bytes instead of reading them", + "an empty file that brings the reader down rather than being reported", + "a picture one pixel wide that divides by zero on the way to a thumbnail", + "storage that reads nought bytes as a failed upload and keeps retrying", + }, + + Says: saidAboutTheMinimalSet, + Expand: expandEmptyAndMinimal, + }) +} + +// formatsList is how the formats parameter is written. +var formatsList = commaList{ + preset: minimalID, + param: "formats", + empty: "no formats were given, so there is nothing to build the set from", + check: checkSetFormat, + keep: lower, + duplicate: repeatedFormat, +} + +func repeatedFormat(first string) string { + return fmt.Sprintf( + "it is the same format as %q and the set would hold that file twice. Every format appears once, because each one stands for one path through your reader", + first) +} + +// checkSetFormat lets the keyword through and asks the registry about the rest. +// +// Whether the keyword is allowed to stand BESIDE a named format is decided +// after the list is parsed rather than here, because here the answer would be +// wrong for "all," - a trailing comma leaves one item, the keyword is alone, +// and a refusal saying it cannot stand beside a named format would be about a +// format nobody wrote. +func checkSetFormat(item string) string { + if strings.EqualFold(item, everyFormat) { + return "" + } + return knownFormat(item) +} + +// chosenFormats is the formats this set covers, in registry order. +// +// Registry order rather than the order somebody typed, and the registry is +// walked here rather than the typing, which is the difference between the +// sentence being true and merely being written down. It was written down and +// false until 2026-09-22: this loop ran over the ids as they arrived, so +// "--formats png,zip" and "--formats zip,png" asked for one set and produced +// two - different recipe text, a different recipe_hash in the manifest, and the +// files listed the other way round. The bytes of the files themselves never +// moved, because a seed comes from the id of a target rather than from its +// place in the list. Found by review, not by a guard, and there is one now. +// +// Why it matters at all: the manifest answers "did this record come from that +// recipe", and two people asking for the same thing have to be able to compare +// their answers. SortChoices holds the same rule for a closed set of values one +// level down. +func chosenFormats(raw string) ([]format.Descriptor, error) { + ids, err := formatsList.parse(raw) + if err != nil { + return nil, err + } + ids, err = spelledOut(ids) + if err != nil { + return nil, err + } + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + + // The keyword shadows a format of the same name, and no format is called + // all - TestNoFormatIsCalledByTheWordThatMeansAllOfThem holds that, so the + // shadow cannot appear without something going red. + out := make([]format.Descriptor, 0, len(ids)) + for _, id := range format.IDs() { + if !wanted[id] { + continue + } + desc, err := format.Get(id) + if err != nil { + return nil, err + } + out = append(out, desc) + } + return out, nil +} + +// spelledOut turns the keyword into the list of ids it stands for, and refuses +// it standing beside a named format. +// +// Its own function rather than a branch inside chosenFormats, because the two +// questions are separate: this one is about what the words mean, the one above +// is about what the registry has. Keeping them apart also keeps each of them +// two levels deep instead of one of them three, which is the shape the crowding +// gate asks for and the reason it asks. +func spelledOut(ids []string) ([]string, error) { + if len(ids) == 1 && ids[0] == everyFormat { + return format.IDs(), nil + } + for _, id := range ids { + if id == everyFormat { + return nil, formatsList.refuse(everyFormat, fmt.Sprintf( + "%q already means every format, so it cannot stand beside a named one. Write it on its own, or name only the formats you want", + everyFormat)) + } + } + return ids, nil +} + +// minimalFile is one file of the set. +type minimalFile struct { + desc format.Descriptor + size int64 + // empty marks the file that is nought bytes, which is the half of this set + // nobody can promise an answer for. + empty bool +} + +func (f minimalFile) id() string { + if f.empty { + return emptyGroup + "_" + f.desc.ID + } + return minimalGroup + "_" + f.desc.ID +} + +func (f minimalFile) name() string { + if f.empty { + return emptyGroup + f.desc.Extension + } + return minimalGroup + f.desc.Extension +} + +// draft is this file as a target of the recipe. +// +// A method rather than a branch inside the loop that builds them, because a +// file of this set knows which half it belongs to and the loop does not need +// to ask. id and name above it are here for the same reason. +func (f minimalFile) draft() recipe.TargetDraft { + if f.empty { + return recipe.TargetDraft{ + ID: f.id(), Format: f.desc.ID, Count: "1", + Size: strconv.FormatInt(f.size, 10), Name: f.name(), Group: emptyGroup, + // Unspecified rather than accept, and the reason says which rule is + // in play rather than what anybody did with it. A file of nought + // bytes is legal and what a system should do with it is its own + // decision - storage keeps it, an upload form usually turns it + // away, and both are defensible. + Expected: "unspecified", ExpectedReason: "size_zero", + } + } + return recipe.TargetDraft{ + ID: f.id(), Format: f.desc.ID, Count: "1", + Size: strconv.FormatInt(f.size, 10), Name: f.name(), Group: minimalGroup, + Expected: "accept", + } +} + +// layOut is the set: the smallest legal file of every format, then the empty +// ones. +// +// The two halves are two groups rather than one, because they carry two +// different expectations and only one of them can be stated with any +// confidence. A file that is valid should be accepted, and that is a positive +// control - if those fail, every refusal the rest of the tool reports means +// nothing. A file of nought bytes is another matter: it is legal, and what a +// system ought to do with it is a policy its owner decides. MF5 and untouchable +// rule 5 both say we do not invent that answer. +// +// Which is why txt and md appear twice. Their smallest legal file IS nought +// bytes, so one entry each would mean either dropping two formats out of the +// positive control or handing two empty files an expectation nobody can back. +// A second file of one byte costs two bytes and keeps both halves honest. +func layOut(descs []format.Descriptor) []minimalFile { + minimal := make([]minimalFile, 0, len(descs)) + var empty []minimalFile + for _, d := range descs { + floor := smallest(d) + if floor > 0 { + minimal = append(minimal, minimalFile{desc: d, size: floor}) + continue + } + minimal = append(minimal, minimalFile{desc: d, size: 1}) + empty = append(empty, minimalFile{desc: d, size: 0, empty: true}) + } + return append(minimal, empty...) +} + +// smallest is the smallest size this build will actually take for the format. +// +// The label is on, because it is on unless somebody passes --clean, and the +// number "tfg formats" prints under MINIMUM is this one. The preset and the +// table have to agree: a set built from a number the table does not show is a +// set nobody can check by hand. +// +// MinBytes beside it is the structural floor with no label, and it is NOT the +// same number - docx, pdf, targz, wav and zip all differ, measured 2026-09-22. +// Asking for MinBytes is refused. +func smallest(d format.Descriptor) int64 { + return d.SmallestAccepted(format.Request{Label: true}) +} + +// saidAboutTheMinimalSet says when the set came out with only one of its halves. +// +// A preset named after both halves that quietly ships one is a promise it did +// not keep, and the whole of untouchable rule 6 is about that kind of silence. +// It happens for a real choice rather than a strange one: "--formats zip" is a +// sensible thing to ask for, and no archive has a legal empty form. +// +// Nothing is said about the other direction. A set that is ALL empty files +// cannot happen, because every format has a smallest legal file and this set +// always holds it. +func saidAboutTheMinimalSet(args Args) []string { + descs, err := chosenFormats(args["formats"]) + if err != nil { + // Expand is about to refuse these same values with a message that names + // the item. A sentence here would be a second opinion on one question. + return nil + } + for _, d := range descs { + if smallest(d) == 0 { + return nil + } + } + return []string{fmt.Sprintf( + "no format in this set has a legal empty form, so the set holds no empty files - only the smallest valid one of each. The formats that go down to nought bytes in this build: %s.", + strings.Join(zeroCapable(), ", "))} +} + +// zeroCapable is every format whose smallest legal file is nought bytes. +// +// Asked of the registry rather than written down. Two formats answer today and +// a third would join them without this sentence noticing, which is the failure +// CLAUDE.md calls a list copied by hand going stale green. +func zeroCapable() []string { + var out []string + for _, id := range format.IDs() { + desc, err := format.Get(id) + if err != nil { + continue + } + if smallest(desc) == 0 { + out = append(out, id) + } + } + return out +} + +func expandEmptyAndMinimal(args Args) ([]byte, error) { + descs, err := chosenFormats(args["formats"]) + if err != nil { + return nil, err + } + + files := layOut(descs) + targets := make([]recipe.TargetDraft, 0, len(files)) + for _, f := range files { + targets = append(targets, f.draft()) + } + + return plan{preset: minimalID, question: minimalQuestion, targets: targets}.source() +} diff --git a/internal/preset/expansion.go b/internal/preset/expansion.go index 677db1cb..7be59795 100644 --- a/internal/preset/expansion.go +++ b/internal/preset/expansion.go @@ -61,5 +61,10 @@ func (e *Expansion) Notes() []string { out = append(out, said) } } + // What those values then laid out, after what we invented, because a + // sentence about the set reads as the consequence of the numbers above it. + if e.Preset.Says != nil { + out = append(out, e.Preset.Says(e.Settled)...) + } return out } diff --git a/internal/preset/preset.go b/internal/preset/preset.go index ac6c3054..6e978f76 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -73,6 +73,27 @@ type Preset struct { // runs and says which number it invented. SaidWhenDefaulted map[string]string + // Says is what to say out loud about the set that was actually laid out, + // given the values it was settled on. + // + // SaidWhenDefaulted above speaks about a value nobody gave us. This speaks + // about what those values then produced, which is a different question and + // had no channel until 2026-09-22. The case that needed one: a preset + // called empty-and-minimal, asked for formats that have no legal empty + // form, builds the minimal half and no empty half - and a preset named + // after both halves that quietly ships one is a promise it did not keep. + // Untouchable rule 6 is about exactly that silence. + // + // It is handed the settled parameters and has to be pure, because every + // surface calls Notes when it happens to need it rather than once. Adding a + // sentence here reaches the command line, the window, the JSON report and + // eject without any of them changing, because all seven consumers already + // go through Expansion.Notes - which is what keeps D1 true without a second + // wiring to remember. + // + // Nil for a preset whose set is the same shape whatever it is given. + Says func(Args) []string + // Expand builds the recipe. It returns source rather than a structure, so // what a run consumes is what eject prints. Expand func(Args) ([]byte, error) diff --git a/internal/preset/sizeboundaries.go b/internal/preset/sizeboundaries.go index 26dd155d..0b2ca88f 100644 --- a/internal/preset/sizeboundaries.go +++ b/internal/preset/sizeboundaries.go @@ -2,10 +2,12 @@ package preset import ( "fmt" + "strconv" "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" ) const ( @@ -13,13 +15,18 @@ const ( defaultLimitText = "10mb" defaultSpreadText = "1B,1kb,1mb" defaultFormat = "pdf" + + // boundariesQuestion is announced by the preset AND written into the header + // of an ejected recipe. Named once rather than typed twice: the two copies + // had already been sitting in this file since it was written. + boundariesQuestion = "Is a size limit enforced exactly where it is declared?" ) func init() { Register(Preset{ ID: boundariesID, Title: "Size boundaries", - Question: "Is a size limit enforced exactly where it is declared?", + Question: boundariesQuestion, Parameters: []format.Property{ { @@ -59,18 +66,66 @@ type offset struct { bytes int64 } -// badSpread is a value the spread parameter does not accept. +// spreadList is how the distances either side of the limit are written. // -// The same type the format registry raises for a value outside its declaration, -// rather than a plain error, and that is a repair rather than a preference. A -// plain error falls through the classifier to RUNTIME, so "--spread notasize" -// told CI this program had a bug instead of saying the value was wrong - -// measured on 2026-08-05, exit 1. The same class as "--set width=abc", which -// was fixed for the same reason two days earlier. +// The shared parser does the splitting, the duplicate and the refusal, and this +// says what one distance has to look like. Two equal distances make two steps +// of the set that are the same file twice and collide on the id built from the +// distance - found by fuzzing on 2026-08-05, where the collision surfaced as a +// recipe the parser refused, complaining about target ids nobody typed. +// Compared as bytes rather than as text, so 1024 and 1kb are caught as well as +// 1B and 1b. +var spreadList = commaList{ + preset: boundariesID, + param: "spread", + empty: "no distances were given, so there is nothing either side of the limit", + check: checkDistance, + same: sizeKey, + keep: lower, + duplicate: repeatedDistance, +} + +func repeatedDistance(first string) string { + return fmt.Sprintf( + "it is the same distance as %q and the set would hold that step twice. Every distance has to be different, because each one names one file either side of the limit", + first) +} + +// badSpread is a value the spread parameter does not accept. func badSpread(value, reason string) error { - return &format.PropertyValueError{ - Format: boundariesID, Key: "spread", Value: value, Reason: reason, + return spreadList.refuse(value, reason) +} + +// checkDistance answers why a piece of the spread is not a distance. +func checkDistance(piece string) string { + // The text of a distance becomes the id of a target and the name of a + // file, so it has to be made of what a size is made of and nothing else. + // Found by fuzzing on 2026-08-05: "1\rB" parses as one byte, because the + // size parser trims the ends and this carriage return is in the middle - + // and the character then reached the recipe source raw and broke the + // document. + if bad := firstUnusable(piece); bad != "" { + return fmt.Sprintf( + "it holds %s, and a distance is written with digits, letters and a dot - such as 1kb, 512 or 1.5mb. Its text becomes the name of a file", bad) + } + n, err := core.ParseSize(piece) + if err != nil { + return err.Error() + } + if n <= 0 { + return "a distance from the limit has to be more than nothing" + } + return "" +} + +// sizeKey is what makes two distances the same one, for the duplicate check. +// Anything checkDistance has passed parses here, so a failure cannot arrive. +func sizeKey(piece string) string { + n, err := core.ParseSize(piece) + if err != nil { + return piece } + return fmt.Sprintf("%d", n) } // firstUnusable names the first character that cannot appear in a distance, @@ -87,46 +142,20 @@ func firstUnusable(piece string) string { } func parseSpread(raw string) ([]offset, error) { - var out []offset - seen := map[int64]string{} - for _, piece := range strings.Split(raw, ",") { - piece = strings.TrimSpace(piece) - if piece == "" { - continue - } - // The text of a distance becomes the id of a target and the name of a - // file, so it has to be made of what a size is made of and nothing - // else. Found by fuzzing on 2026-08-05: "1\rB" parses as one byte, - // because the size parser trims the ends and this carriage return is in - // the middle - and the character then reached the recipe source raw and - // broke the document. The comment on render() claimed no value there - // needed quoting. That was true of every value except this one. - if bad := firstUnusable(piece); bad != "" { - return nil, badSpread(piece, fmt.Sprintf( - "it holds %s, and a distance is written with digits, letters and a dot - such as 1kb, 512 or 1.5mb. Its text becomes the name of a file", bad)) - } + pieces, err := spreadList.parse(raw) + if err != nil { + return nil, err + } + out := make([]offset, 0, len(pieces)) + for _, piece := range pieces { + // Parsed again rather than carried through the list, because the list + // deals in text for every preset and only this one wants the number. + // checkDistance has already refused anything that would fail here. n, err := core.ParseSize(piece) if err != nil { return nil, badSpread(piece, err.Error()) } - if n <= 0 { - return nil, badSpread(piece, "a distance from the limit has to be more than nothing") - } - // Two equal distances make two steps of the set that are the same file - // twice, and they collide on the id built from the distance. Found by - // fuzzing on 2026-08-05: the collision surfaced as a recipe the parser - // refused, complaining about target ids nobody typed. Compared as bytes - // rather than as text, so 1024 and 1kb are caught as well as 1B and 1b. - if first, repeated := seen[n]; repeated { - return nil, badSpread(piece, fmt.Sprintf( - "it is the same distance as %q and the set would hold that step twice. Every distance has to be different, because each one names one file either side of the limit", - first)) - } - seen[n] = piece - out = append(out, offset{text: strings.ToLower(piece), bytes: n}) - } - if len(out) == 0 { - return nil, badSpread(raw, "no distances were given, so there is nothing either side of the limit") + out = append(out, offset{text: piece, bytes: n}) } return out, nil } @@ -204,11 +233,15 @@ func expandSizeBoundaries(args Args) ([]byte, error) { } } - plan := steps(limit, spread) - if err := reachable(plan, desc, limit); err != nil { + set := steps(limit, spread) + if err := reachable(set, desc, limit); err != nil { return nil, err } - return render(plan, desc, limitText), nil + return plan{ + preset: boundariesID, + question: boundariesQuestion, + targets: draftsOfSteps(set, desc, limitText), + }.source() } // reachable refuses the whole set when any one file of it is out of reach. @@ -261,47 +294,39 @@ func largest(plan []step, limit int64) int64 { return deepest } -// render writes the recipe. +// draftsOfSteps is the set as targets, ready for the composer. // -// Source rather than a structure, so eject prints what a run consumes. +// This used to print the document itself, line by line, with a comment saying +// that was safe because every value was one the package built itself. The +// comment was wrong until 2026-08-05: the id carries the caller's own text, so +// "1\rB" reached the document raw and broke it, because the size parser trims +// the ends and that carriage return sat in the middle. Found by fuzzing rather +// than by reading. // -// Nothing here is quoted, and that is safe because of where the values come -// from rather than because writing YAML by hand is safe: a byte count, a format -// id the registry knows, and an id built from the text of a distance. -// -// That last one is the one to watch, and it was wrong until 2026-08-05. This -// comment used to say every value was one the package built itself, and the id -// carries the caller's own text - so "1\rB" reached the document raw and broke -// it, because the size parser trims the ends and that carriage return sat in -// the middle. Found by fuzzing, not by reading. parseSpread now refuses any -// character a size is not written with, which is what makes the sentence above -// true rather than merely confident. -func render(plan []step, desc format.Descriptor, limitText string) []byte { - var b strings.Builder - b.WriteString("# Generated by: tfg preset eject " + boundariesID + "\n") - b.WriteString("# " + "Is a size limit enforced exactly where it is declared?" + "\n") - b.WriteString("#\n") - b.WriteString("# Edit it, commit it, it is an ordinary recipe from here on.\n\n") - b.WriteString("version: 1\n") - b.WriteString("targets:\n") - - for _, s := range plan { - fmt.Fprintf(&b, " - id: %s\n", s.id) - fmt.Fprintf(&b, " format: %s\n", desc.ID) - fmt.Fprintf(&b, " count: 1\n") - fmt.Fprintf(&b, " size: %d\n", s.size) - // The id stays as it was. It derives the seed, so putting the limit in - // it would move the bytes of every file in this set for a change that - // is about telling two directories apart. - fmt.Fprintf(&b, " name: %s_%s%s\n", limitText, s.id, desc.Extension) - fmt.Fprintf(&b, " group: %s\n", boundariesID) - if s.accept { - b.WriteString(" expected: accept\n") - continue +// parseSpread refuses that character now, and this no longer writes YAML at +// all - plan.source hands the values to the marshaller, which does the quoting +// and owns the shape of the document. Two defences rather than one, and the +// second one cannot be forgotten by the next preset. +func draftsOfSteps(set []step, desc format.Descriptor, limitText string) []recipe.TargetDraft { + out := make([]recipe.TargetDraft, 0, len(set)) + for _, s := range set { + draft := recipe.TargetDraft{ + ID: s.id, + Format: desc.ID, + Count: "1", + Size: strconv.FormatInt(s.size, 10), + // The id stays as it was. It derives the seed, so putting the limit + // in it would move the bytes of every file in this set for a change + // that is about telling two directories apart. + Name: limitText + "_" + s.id + desc.Extension, + Group: boundariesID, + Expected: "accept", } - b.WriteString(" expected:\n") - b.WriteString(" outcome: reject\n") - b.WriteString(" reason: size_limit\n") + if !s.accept { + draft.Expected = "reject" + draft.ExpectedReason = "size_limit" + } + out = append(out, draft) } - return []byte(b.String()) + return out } diff --git a/internal/recipe/compose.go b/internal/recipe/compose.go index e5afb54b..c06a0b3d 100644 --- a/internal/recipe/compose.go +++ b/internal/recipe/compose.go @@ -2,6 +2,7 @@ package recipe import ( "fmt" + "strconv" "github.com/goccy/go-yaml" @@ -138,7 +139,18 @@ func Compose(d Document) ([]byte, error) { } doc = append(doc, yaml.MapItem{Key: "targets", Value: targets}) - return yaml.Marshal(doc) + // Sequences are indented under their key, which is not the marshaller's + // default and is what every recipe in docs/RECIPE.md looks like. + // + // It matters because of what somebody does with a composed recipe next. The + // header of an ejected one says "edit it, commit it, it is an ordinary + // recipe from here on", so a target gets pasted in from the documents - and + // at the flat default, that paste sits at a different indent from the + // entries above it and the file stops parsing, with the error pointing at + // the line the person just added. Caught by + // TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended + // on 2026-09-22, which appends a target the way a person would. + return yaml.MarshalWithOptions(doc, yaml.IndentSequence(true)) } // withSection is the preset's parameters, sorted for the reason properties @@ -173,13 +185,19 @@ func targetEntry(t TargetDraft) yaml.MapSlice { entry = append(entry, yaml.MapItem{Key: key, Value: value}) } } + // The keys where a number belongs are written as a number. See bareNumber. + addNumber := func(key, value string) { + if value != "" { + entry = append(entry, yaml.MapItem{Key: key, Value: bareNumber(value)}) + } + } add("id", t.ID) add("format", t.Format) - add("count", t.Count) - add("size", t.Size) + addNumber("count", t.Count) + addNumber("size", t.Size) add("size-range", t.SizeRange) - add("boundary", t.Boundary) + addNumber("boundary", t.Boundary) add("name", t.Name) add("group", t.Group) @@ -204,10 +222,10 @@ func targetEntry(t TargetDraft) yaml.MapSlice { one = append(one, yaml.MapItem{Key: "format", Value: c.Format}) } if c.Count != "" { - one = append(one, yaml.MapItem{Key: "count", Value: c.Count}) + one = append(one, yaml.MapItem{Key: "count", Value: bareNumber(c.Count)}) } if c.Size != "" { - one = append(one, yaml.MapItem{Key: "size", Value: c.Size}) + one = append(one, yaml.MapItem{Key: "size", Value: bareNumber(c.Size)}) } inside = append(inside, one) } @@ -216,6 +234,31 @@ func targetEntry(t TargetDraft) yaml.MapSlice { return entry } +// bareNumber is a value written the way a person writes it - 1024 rather than +// "1024". +// +// Everything in a Document is text, because a screen holds text and Parse is +// the one thing allowed to judge it. Marshalling that text straight gives +// size: "1024" - correct YAML, read back as the same number, and not what this +// project's own documents show. RECIPE.md writes count: 25 and size: 2mb, and a +// recipe this program composed is a document somebody then edits and commits, +// so it has to look like the ones they have already read. Noticed 2026-09-22, +// when the first preset to compose its recipe rather than print it put +// size: "74" in front of somebody. +// +// Only a plain decimal is turned, and only where a number belongs. A size may +// be "2mb" and a name may be "007" - a name quietly becoming the number seven +// would be this doing harm rather than tidying. The round trip through +// FormatInt is what refuses "007", "+5" and " 5": they parse, and they are not +// how the number is written. +func bareNumber(value string) any { + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n < 0 || value != strconv.FormatInt(n, 10) { + return value + } + return n +} + // expectationEntry writes the short form when there is no reason and the long // one when there is, which is the same choice a person writing the file by hand // makes. Nil when nothing was stated. diff --git a/web/content/en/site.json b/web/content/en/site.json index 351aa25b..432ca61c 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -95,6 +95,7 @@ "143": "Stopped by a signal, which is what a CI timeout looks like." }, "presets": { + "empty-and-minimal": "Does a file that is valid and as small as the format allows get through?", "size-boundaries": "Is a size limit enforced exactly where it is declared?" }, "commands": { diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 8328b64c..9bd1fc00 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -95,6 +95,7 @@ "143": "Zatrzymane sygnałem - tak wygląda przekroczony czas w CI." }, "presets": { + "empty-and-minimal": "Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?", "size-boundaries": "Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?" }, "commands": { diff --git a/web/public/docs/index.html b/web/public/docs/index.html index b54fd41c..238dbe56 100644 --- a/web/public/docs/index.html +++ b/web/public/docs/index.html @@ -307,6 +307,10 @@

What is a preset?

you can edit it from there.

    +
  • +

    empty-and-minimal

    +

    Does a file that is valid and as small as the format allows get through?

    +
  • size-boundaries

    Is a size limit enforced exactly where it is declared?

    diff --git a/web/public/pl/dokumentacja/index.html b/web/public/pl/dokumentacja/index.html index 84446c27..5b59a2e6 100644 --- a/web/public/pl/dokumentacja/index.html +++ b/web/public/pl/dokumentacja/index.html @@ -308,6 +308,10 @@

    Czym jest preset?

    przepis, więc możesz go od tego miejsca edytować.

      +
    • +

      empty-and-minimal

      +

      Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?

      +
    • size-boundaries

      Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?