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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ because it turns other people's test suites red.

### Changed

- **The window opens on a preset that was chosen rather than sorted.** The
Presets tab, and the "Build on a preset" section of the batches screen,
used to start on whichever preset came first alphabetically - so what you
saw when you opened the tab changed whenever a preset was added. A preset
says now whether it is the one to start on, and `empty-and-minimal` is it:
pressing Generate without touching anything writes 32 214 B rather than the
73 MB the size-boundaries defaults come to, and its set means something
without a number from you first.
- **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
Expand Down Expand Up @@ -273,6 +281,17 @@ because it turns other people's test suites red.

### Fixed

- **A refusal about two settings that bound each other now says how far over
you are.** Asking for a picture of 20000 by 2001 pixels was turned down with
"together they come to 40 megapixels and the limit is 40" - the same number
twice, because the counts were rounded to whole megapixels and the request
is 40.02 of them. The limit had no unit after it either. Both counts are
written out exactly when rounding would put them on one number ("together
they come to 40 020 000 pixels and the limit is 40 000 000 pixels"), and the
readable form is kept where it still tells you something ("400 megapixels
and the limit is 40 megapixels"). The same sentence is used by every format
with a rule of this kind: `avif`, `gif`, `jpg`, `jxl`, `png` and `xlsx`.

- **The whole head row of a section opens and closes it.** Until now only the
small arrow after a section's title was the target, so a click on "Notes
for the manifest" or on "Settings for png" did nothing and the section
Expand Down
15 changes: 14 additions & 1 deletion internal/core/humanise.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,20 @@ func HumanBytes(n int64) string {
// Machine output is untouched on purpose. Nothing in a manifest or under --json
// goes through here, because a number there is a number and not a sentence.
func ExactBytes(n int64) string {
return groupedInThrees(strconv.FormatInt(n, 10)) + " B"
return Exactly(n) + " B"
}

// Exactly is a whole number with a space every three digits, for a count that
// has to be read precisely rather than approximately.
//
// ExactBytes above it is this with a unit after it. Split out on 2026-09-22,
// when a refusal about a joint limit had to print two counts that the rounded
// form had put on one number: a picture of 20000x2001 comes to 40 020 000
// pixels against a limit of 40 000 000, and the sentence read "they come to 40
// megapixels and the limit is 40" - two identical numbers and a refusal nobody
// could answer. See format.JointLimit and O232.
func Exactly(n int64) string {
return groupedInThrees(strconv.FormatInt(n, 10))
}

// groupedInThrees puts a space every three digits, counting from the right.
Expand Down
2 changes: 1 addition & 1 deletion internal/format/avif/avif.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "width", By: "height", Max: maxPixels,
Unit: "megapixels", Per: 1_000_000,
Unit: "megapixels", Per: 1_000_000, Base: "pixels",
Why: "the encoder holds the whole picture in memory while it works",
}},
GeneratorVersion: generatorVersion,
Expand Down
42 changes: 39 additions & 3 deletions internal/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,16 @@ type JointLimit struct {
// "400000000 and the limit is 40000000" is not. Per of nought means one.
Unit string
Per int64
// Base is what the product itself counts, for the sentence that has to be
// exact: "pixels", "cells".
//
// It exists because the readable form above cannot always be used. Rounding
// two counts that differ can land them on one number, and then the refusal
// says "they come to 40 megapixels and the limit is 40" - measured on
// 2026-09-22 for a picture of 20000x2001, which is 40 020 000 pixels
// against a limit of 40 000 000. A person reading that has been told
// nothing. See Allows.
Base string
// Why is the reason, in the words the refusal uses.
Why string
}
Expand All @@ -228,11 +238,37 @@ type JointLimit struct {
// be a branch nothing could ever reach, and an unreachable branch reads as a
// protection somebody is relying on.
func (j JointLimit) Allows(of, by int64) (bad string) {
if of*by <= j.Max {
got := of * by
if got <= j.Max {
return ""
}
return fmt.Sprintf("together they come to %d %s and the limit is %d, because %s",
of*by/j.per(), j.Unit, j.Max/j.per(), j.Why)
asked, allowed := j.readably(got)
return fmt.Sprintf("together they come to %s and the limit is %s, because %s",
asked, allowed, j.Why)
}

// readably is the pair of counts as a person reads them, and it never puts two
// different counts on one number.
//
// The rounded form is offered first, because that is the one worth reading:
// "400 megapixels and the limit is 40 megapixels" is a sentence somebody can
// act on. It is stood down when both counts round to the same text, which is
// not a corner case - it is what a request just over the limit looks like.
// Measured on 2026-09-22 (O232): 20000x2001 is 40 020 000 pixels, the limit is
// 40 000 000, and the sentence read "they come to 40 megapixels and the limit
// is 40". Two identical numbers, and no way to tell how far over it was.
//
// Both halves carry the unit now. Only the first one did, so the limit was a
// bare number taking its noun from four words earlier.
//
// Adding decimal places was the other way out and it does not work: 40 000 001
// against 40 000 000 collides at every fixed number of places.
func (j JointLimit) readably(got int64) (asked, allowed string) {
if j.per() > 1 && got/j.per() != j.Max/j.per() {
return fmt.Sprintf("%d %s", got/j.per(), j.Unit),
fmt.Sprintf("%d %s", j.Max/j.per(), j.Unit)
}
return core.Exactly(got) + " " + j.Base, core.Exactly(j.Max) + " " + j.Base
}

// Describe is the rule as one sentence, for the format list and for a window.
Expand Down
2 changes: 1 addition & 1 deletion internal/format/gif/gif.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "width", By: "height", Max: maxPixels,
Unit: "megapixels", Per: 1_000_000,
Unit: "megapixels", Per: 1_000_000, Base: "pixels",
Why: "the picture is held in memory while it is encoded",
}},
GeneratorVersion: generatorVersion,
Expand Down
2 changes: 1 addition & 1 deletion internal/format/jpg/jpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "width", By: "height", Max: maxPixels,
Unit: "megapixels", Per: 1_000_000,
Unit: "megapixels", Per: 1_000_000, Base: "pixels",
Why: "the picture is held in memory while it is encoded",
}},
GeneratorVersion: generatorVersion,
Expand Down
2 changes: 1 addition & 1 deletion internal/format/jxl/jxl.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "width", By: "height", Max: maxPixels,
Unit: "megapixels", Per: 1_000_000,
Unit: "megapixels", Per: 1_000_000, Base: "pixels",
Why: "the encoder holds the whole picture in memory while it works",
}},
GeneratorVersion: generatorVersion,
Expand Down
2 changes: 1 addition & 1 deletion internal/format/png/png.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "width", By: "height", Max: maxPixels,
Unit: "megapixels", Per: 1_000_000,
Unit: "megapixels", Per: 1_000_000, Base: "pixels",
Why: "the picture is held in memory while it is encoded",
}},
GeneratorVersion: generatorVersion,
Expand Down
2 changes: 1 addition & 1 deletion internal/format/xlsx/xlsx.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func init() {
},
JointLimits: []format.JointLimit{{
Of: "rows", By: "columns", Max: maxCells,
Unit: "million cells", Per: 1_000_000,
Unit: "million cells", Per: 1_000_000, Base: "cells",
Why: "the sheet is built in memory before it is packaged",
}},
GeneratorVersion: generatorVersion,
Expand Down
148 changes: 148 additions & 0 deletions internal/guard/jointlimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package guard

import (
"strings"
"testing"

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

// A refusal about a joint limit never prints the request and the limit as one
// number.
//
// The rule binding two settings reports both counts in a unit a person reads -
// megapixels rather than pixels - and it reported them by dividing, which
// truncates. So a request just over the limit came back as the limit: a picture
// of 20000x2001 is 40 020 000 pixels against a limit of 40 000 000, and the
// sentence read "together they come to 40 megapixels and the limit is 40".
// Two identical numbers, no unit on the second one, and nothing in it a person
// could act on - which is the third part of D6 missing. Measured 2026-09-22,
// O232.
//
// The pairs below are asked of the registry's own declarations rather than of
// numbers written here, so a format arriving with a joint limit of its own is
// covered without anybody remembering to come back.
func TestNoJointLimitRefusalPrintsTheRequestAndTheLimitAsOneNumber(t *testing.T) {
limits := declaredJointLimits(t)

for _, l := range limits {
// Three requests, and the middle one is the whole point: one unit over
// the limit is exactly where rounding used to hide the difference.
for _, over := range []int64{1, l.Max / 2, l.Max * 9} {
got := l.Max + over
bad := l.Allows(got, 1)
if bad == "" {
t.Errorf("%s: %d is past the limit of %d and the rule allowed it",
l.Of+" times "+l.By, got, l.Max)
continue
}
counts := countsIn(bad)
if len(counts) < 2 {
t.Errorf("%s: the refusal does not read as two counts: %q",
l.Of+" times "+l.By, bad)
continue
}
asked, allowed := counts[0], counts[1]
if asked.number == allowed.number {
t.Errorf("%s: asked for %d against a limit of %d and the refusal says %q - "+
"the two counts print as the same thing, so it says nothing",
l.Of+" times "+l.By, got, l.Max, bad)
}
// Both counts carry the same noun. The limit used to be a bare
// number taking its noun from four words earlier, which reads as a
// count of something else - and nothing said so until a mutation
// took the unit off and every guard stayed green.
switch {
case allowed.unit == "":
t.Errorf("%s: the limit in %q is a bare number with no unit after it",
l.Of+" times "+l.By, bad)
case asked.unit != allowed.unit:
t.Errorf("%s: the refusal counts the request in %q and the limit in %q: %q",
l.Of+" times "+l.By, asked.unit, allowed.unit, bad)
}
}
}
}

// Every joint limit says what its product counts.
//
// Base is what the exact form of the sentence is built on, so a declaration
// without one ends "together they come to 40 020 000 and the limit is
// 40 000 000 " - a sentence with a hole where its noun should be, and only on
// the path that is taken when the readable form cannot be used. That is the
// path nobody looks at, which is why it is asserted here rather than left to be
// noticed.
func TestEveryJointLimitSaysWhatItCounts(t *testing.T) {
for _, l := range declaredJointLimits(t) {
if strings.TrimSpace(l.Base) == "" {
t.Errorf("the rule binding %s and %s does not say what it counts, so its exact "+
"refusal has no noun in it", l.Of, l.By)
}
if strings.TrimSpace(l.Unit) == "" {
t.Errorf("the rule binding %s and %s does not say what it reports in", l.Of, l.By)
}
if strings.TrimSpace(l.Why) == "" {
t.Errorf("the rule binding %s and %s gives no reason, and D6 asks for one",
l.Of, l.By)
}
}
}

// declaredJointLimits is every rule the registry holds, and it refuses to hand
// back none - a guard walking an empty list passes against any rule ever
// written.
func declaredJointLimits(t *testing.T) []format.JointLimit {
t.Helper()
var out []format.JointLimit
for _, d := range format.All() {
out = append(out, d.JointLimits...)
}
if len(out) == 0 {
t.Fatal("no format declares a rule binding two settings, so this guard checked nothing")
}
return out
}

// counted is one number in a refusal and the word that follows it.
type counted struct {
number string
unit string
}

// countsIn pulls the counts out of a refusal with the word after each one,
// ignoring the spaces that group digits.
//
// The word is read with firstWordOf from the doc-comment guard, which trims the
// punctuation a sentence puts after its last noun - so a bare count followed by
// a comma comes back with no unit at all, which is exactly the state being
// looked for.
//
// Read out of the sentence rather than recomputed, because what is being
// checked is what a person SEES. A guard comparing the numbers the rule holds
// would agree with the rule and say nothing about the words it chose.
func countsIn(sentence string) []counted {
var found []counted
var digits strings.Builder
flush := func(rest string) {
if digits.Len() == 0 {
return
}
found = append(found, counted{number: digits.String(), unit: firstWordOf(rest)})
digits.Reset()
}
for i := 0; i < len(sentence); i++ {
c := sentence[i]
switch {
case c >= '0' && c <= '9':
digits.WriteByte(c)
case c == ' ' && digits.Len() > 0 && i+1 < len(sentence) &&
sentence[i+1] >= '0' && sentence[i+1] <= '9':
// A space inside a grouped number, not the end of one.
default:
flush(sentence[i:])
}
}
flush("")
return found
}
Loading
Loading