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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<id>` names the preset
and `with:` fills its parameters, written the way the flags take them
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/presetcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions internal/core/humanise.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions internal/guard/actionbarheight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions internal/guard/boxwidth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion internal/guard/bytecount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions internal/guard/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/guard/extends_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/guard/fieldmarking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
5 changes: 5 additions & 0 deletions internal/guard/filekind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
11 changes: 10 additions & 1 deletion internal/guard/menudefault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Loading
Loading