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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,23 @@ jobs:
ogrinfo --version
python -c "import shapely; print('shapely', shapely.__version__, 'GEOS', shapely.geos_version_string)"

# Mail is read by mailparser beside Python's email package - the
# parser much of the mail handling written in Node uses, and the one
# that refuses a message of more than 1000 MIME entities
# (docs/EML-2026-10-07.md section 5.1). Pinned, installed outside the
# checkout, and named to the guard through TFG_MAILPARSER. Asked for
# its version, so a package that did not install stops this step, and
# the guard fails if it is named and reads nothing.
#
# Without a committed lockfile, on purpose for now: one in the
# repository puts the whole tree into the dependency graph, and the
# licence gate then blocks two packages of it (review of #173,
# docs/REVIEW-173-2026-10-07.md). Whether CI-only tools belong under
# that gate is the owner's decision, not a fix.
npm install --prefix "$RUNNER_TEMP/mailparser" mailparser@3.9.36
echo "TFG_MAILPARSER=$RUNNER_TEMP/mailparser/node_modules/mailparser" >> "$GITHUB_ENV"
node -e "console.log('mailparser', require(process.argv[1] + '/package.json').version)" "$RUNNER_TEMP/mailparser/node_modules/mailparser"

# The distribution's Pillow cannot open an AVIF - measured on this
# runner, PIL.UnidentifiedImageError on a file the structural checker
# had just called sound. Installed for the SAME interpreter the oracle
Expand Down
25 changes: 22 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ because it turns other people's test suites red.
refuses a text value of ten million characters. At precision 3 or less a
line has fewer points than that, one for each step of longitude the globe
has, and asking for more is refused with the number that fits.
- **Mail: the `eml` format.** A mail message (RFC 5322 with MIME) at the
exact size you ask for, with real files of any other format attached in
base64 - `attachments`, `attachment_format` and `attachment_size`, or
`contains` for files that are not all alike. The size is the message's,
about a third more than the files it carries, and the manifest records
both, with the number of MIME entities and the subject and Message-ID a
test can find the message by. `body` gives a text part (the default), an
HTML part, or both with the same words. `text_encoding` keeps the text to
7bit, or adds a line in Polish and Japanese in quoted-printable or 8bit.
`headers` puts letters outside ASCII into the subject, the names of the
people and the names of attached files, encoded the way RFC 2047 and RFC
2231 say or raw as RFC 6532 allows. `filename_style` writes those names
four ways, and readers do not agree on two of them: Go's standard library
reads no name from `content-type` and the encoded text itself from
`rfc2047`. `line_endings` gives `crlf` or `lf`. Read by Python's email
package, mailparser and Go's standard library, and checked line by line
against RFC 5322, which none of the three does. The smallest message at
the default settings is 289 B. mailparser refuses a message of more than
1000 MIME entities, which 999 attached files and the text reach.
- **GeoJSON: holes, features with no place, both types of id, and bbox.**
`holes` gives every polygon up to 100 000 holes of four points, each running
the other way round to its outline as RFC 7946 asks. Holes need 6 or more
Expand Down Expand Up @@ -194,10 +213,10 @@ because it turns other people's test suites red.

### Changed

- **`empty-and-minimal` now includes the films and GeoJSON.** The preset builds the
- **`empty-and-minimal` now includes the films, GeoJSON and mail.** The preset builds the
smallest file of every format this build has, so with `formats` left at
`all` it gains a `webm`, an `mp4` and a `geojson` target, and the recipe
`tfg preset eject empty-and-minimal` prints is three targets longer. A manifest from a run of that preset records the
`all` it gains a `webm`, an `mp4`, a `geojson` and an `eml` target, and the recipe
`tfg preset eject empty-and-minimal` prints is four targets longer. A manifest from a run of that preset records the
hash of that recipe, so its `recipe_hash` differs from one written before.
No generated file changes, and a run that names its `formats` is untouched.
- **An empty text box in the window reads "not set"** instead of "worked out
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

**Testing Files Generator** is a tool for QA engineers and developers who need real
files to test against - an upload form, a parser, anything that takes a file and
has an opinion about it. You pick one of its 29 formats and the size you want,
has an opinion about it. You pick one of its 30 formats and the size you want,
and you get **exactly that**: ask for a 10 MB PDF and you get a PDF that a reader
will open, at 10 MB to the byte. Every run also leaves a manifest saying **what
your system should do with each file**, which is the part other generators leave
Expand All @@ -23,7 +23,7 @@ needs it finds out it exists.

- **Hit an exact size, to the byte** - ask for 10485761 bytes and get exactly
that, never a silently rounded file.
- **Write 29 real formats** - a generated PNG opens in an image viewer, a DOCX
- **Write 30 real formats** - a generated PNG opens in an image viewer, a DOCX
opens in Word, a ZIP extracts. Not padded zeros with an extension.
- **Say what should happen to each file** - the manifest carries an expected
outcome, so your test reads the assertion instead of you writing it out.
Expand Down Expand Up @@ -166,6 +166,7 @@ owns it, at the exact size you asked for:
| 📝 **Text and markup** | `txt`, `md`, `csv`, `json`, `xml`, `html`, `log` |
| ⚙️ **Configuration** | `yaml`, `toml` |
| 🗺️ **Maps** | `geojson` (RFC 7946) |
| ✉️ **Mail** | `eml` (RFC 5322 with MIME, real files attached) |
| 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) |
| 🔊 **Audio** | `wav` |
| 🎞️ **Video** | `webm`, `mp4` (both AV1, no sound) |
Expand Down Expand Up @@ -618,6 +619,7 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `txt`, `md`, `xml` | `encoding`, `bom` |
| `json` | `formatting` |
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` |
| `eml` | `body`, `text_encoding`, `headers`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style` |
| `svg` | `width`, `height` |
| `html` | `structure` |
| `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for |
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func settleTarget(t *Target, opt Options, seen map[string]bool) (format.Descript
// ignoring contains would give an archive with none of the files somebody
// listed, reported as a success - the file looks right and the test suite
// believes it.
if len(t.Contains) > 0 && !desc.Container {
if len(t.Contains) > 0 && desc.Container == nil {
return format.Descriptor{}, &format.NotAContainerError{Format: t.Format, Containers: format.Containers()}
}

Expand Down
1 change: 1 addition & 0 deletions internal/format/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/bmp"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/csvfile"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/docx"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/eml"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/geojson"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/gif"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/htmlfile"
Expand Down
71 changes: 62 additions & 9 deletions internal/format/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,46 @@ func Axes(names ...string) []format.Property {
// is what an archive may not hold, since an archive inside an archive needs a
// depth limit that does not exist yet.
func Groups(id string, r format.Request) ([]format.Content, error) {
return GroupsThrough(id, r, archiveDoor)
}

// ArchiveMembers is how an archive names what it holds, for its descriptor.
var ArchiveMembers = format.Members{Count: Entries, Format: EntryFormat, Size: EntrySize}

// MaxMembers is the most files a container holds, through either door. A
// container that is not an archive declares its own count with this ceiling,
// so the two doors of every container stop at one number.
const MaxMembers = maxEntries

// DefaultSizeText is how big each file inside is when nothing says, written
// the way a declaration shows it.
const DefaultSizeText = defaultSizeText

// Door is how a container names its contents, for GroupsThrough.
//
// The rules are one set for every container - both doors at once refused, the
// container never inside itself, one ceiling on both - and what differs is the
// words: which keys, how many files when nothing says, and the noun in the
// sentence that asks for fewer.
type Door struct {
Members format.Members
DefaultCount int
// Fewer is the remedy when more files are asked for than MaxMembers.
Fewer core.Said
}

// archiveDoor is the door both archives share.
var archiveDoor = Door{
Members: ArchiveMembers,
DefaultCount: defaultEntries,
Fewer: core.Says("format.AskForEntriesOrFewer", "Ask for %d entries or fewer.", core.A("MaxEntries", maxEntries)),
}

// GroupsThrough is Groups for a container that names its contents its own way.
func GroupsThrough(id string, r format.Request, door Door) ([]format.Content, error) {
keys := door.Members
var stated []string
for _, key := range []string{Entries, EntryFormat, EntrySize} {
for _, key := range []string{keys.Count, keys.Format, keys.Size} {
if _, ok := r.Properties[key]; ok {
stated = append(stated, key)
}
Expand All @@ -262,7 +300,7 @@ func Groups(id string, r format.Request) ([]format.Content, error) {
// which is a legitimate thing to ask for, and it is a different statement
// from saying nothing at all.
if r.Contains != nil {
return fromContains(id, r, stated)
return fromContains(id, r, stated, door.Fewer)
}

if r.SizeFromContents {
Expand All @@ -271,19 +309,19 @@ func Groups(id string, r format.Request) ([]format.Content, error) {
return nil, core.Defect(fmt.Errorf("%s: the size was left to the contents and there are none", id))
}

entries, err := intProperty(id, r.Properties, Entries, defaultEntries, 0, maxEntries)
entries, err := intProperty(id, r.Properties, keys.Count, door.DefaultCount, 0, maxEntries)
if err != nil {
return nil, err
}
// Sizes in properties use the same syntax as --size. Anything else would
// mean entry_size=200kb failing while size=200kb works, which nobody would
// predict.
entrySize, err := sizeProperty(id, r.Properties, EntrySize, defaultSize)
entrySize, err := sizeProperty(id, r.Properties, keys.Size, defaultSize)
if err != nil {
return nil, err
}
entryFmt := DefaultFormat
if v, ok := r.Properties[EntryFormat]; ok && v != "" {
if v, ok := r.Properties[keys.Format]; ok && v != "" {
entryFmt = v
}
if entryFmt == id {
Expand All @@ -297,7 +335,7 @@ func Groups(id string, r format.Request) ([]format.Content, error) {
// Split out of Groups so the branching stays under the ceiling the shape gates
// hold, and because it is one subject: everything here is about a contains list
// and nothing about it reads a property.
func fromContains(id string, r format.Request, stated []string) ([]format.Content, error) {
func fromContains(id string, r format.Request, stated []string, fewer core.Said) ([]format.Content, error) {
if len(stated) > 0 {
return nil, &format.ContentsConflictError{Format: id, Keys: stated}
}
Expand All @@ -319,7 +357,7 @@ func fromContains(id string, r format.Request, stated []string) ([]format.Conten
// to the exit code: a plain error here would have landed on 1 while
// the same request through entries lands on 4, which is the same
// disagreement one level further down.
return nil, tooMany(id, "contains", asked)
return nil, tooMany(id, "contains", asked, fewer)
}
return r.Contains, nil
}
Expand All @@ -328,13 +366,13 @@ func fromContains(id string, r format.Request, stated []string) ([]format.Conten
//
// TestBothWaysOfAskingForEntriesShareOneCeiling compares the reason the two
// produce, and before this it compared two sentences somebody had typed twice.
func tooMany(id, key string, asked int) *format.PropertyValueError {
func tooMany(id, key string, asked int, fewer core.Said) *format.PropertyValueError {
return &format.PropertyValueError{
Format: id,
Key: key,
Value: strconv.Itoa(asked),
Reason: core.Says("format.ItTakesAWholeNumberFrom", "it takes a whole number from 0 to %d", core.A("MaxEntries", maxEntries)),
Remedy: core.Says("format.AskForEntriesOrFewer", "Ask for %d entries or fewer.", core.A("MaxEntries", maxEntries)),
Remedy: fewer,
}
}

Expand Down Expand Up @@ -365,3 +403,18 @@ func intProperty(id string, props map[string]string, key string, fallback, min,
}
return n, nil
}

// ContentSummary is what a container holds, in the manifest, so a test can
// assert on it without opening the file. One copy for every container, so the
// key names a test reads cannot differ between them.
func ContentSummary(groups []format.Content) []map[string]any {
out := make([]map[string]any, 0, len(groups))
for _, g := range groups {
out = append(out, map[string]any{
"format": g.Format,
"count": g.Count,
"bytes": g.Bytes,
})
}
return out
}
1 change: 1 addition & 0 deletions internal/format/avif/avif.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func init() {
ID: "avif",
Name: "AV1 Image File Format",
Extension: ".avif",
MediaType: "image/avif", // IANA, Alliance for Open Media
Fidelity: format.FidelityFull,
Determinism: format.DeterminismByte,

Expand Down
1 change: 1 addition & 0 deletions internal/format/bmp/bmp.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func init() {
ID: "bmp",
Name: "Windows Bitmap",
Extension: ".bmp",
MediaType: "image/bmp", // IANA, RFC 7903
Fidelity: format.FidelityFull,
Determinism: format.DeterminismByte,

Expand Down
113 changes: 113 additions & 0 deletions internal/format/container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package format

import (
"sort"
"strings"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
)

// What a container is, apart from the format it writes: how it names its
// contents, and the three ways asking a format for contents can be refused.
// Split out of format.go on 2026-10-07, when eml - the first container that is
// not an archive - pushed that file past the size the shape guard allows.

// Members is how a container names the settings for contents of one format.
type Members struct {
Count, Format, Size string
}

// NotAContainerError is contains asked of a format that holds nothing.
type NotAContainerError struct {
Format string
Containers []string
}

// What happened, what can do it instead, and what to do about it.
func (e *NotAContainerError) What() string { return e.what().String() }

func (e *NotAContainerError) what() core.Said {
return core.Says("format.NotAContainer", "%s holds no other files, so it cannot take contains", core.A("Format", e.Format))
}

func (e *NotAContainerError) Why() string { return e.why().String() }

func (e *NotAContainerError) why() core.Said {
return core.Says("format.NotAContainerWhy", "the formats that can are %s", core.A("Containers", strings.Join(e.Containers, ", ")))
}

func (e *NotAContainerError) Instead() string { return e.instead().String() }

func (e *NotAContainerError) instead() core.Said {
return core.Says("format.NotAContainerFix", "Drop contains, or change the format")
}

// Parts is what happened, why and what to do instead, for a reader that lays
// them out apart and in its own language.
func (e *NotAContainerError) Parts() (what, why, instead core.Said) {
return e.what(), e.why(), e.instead()
}

func (e *NotAContainerError) Error() string { return e.Said().String() }

// Said is the whole refusal, for a window that says it in its own language.
func (e *NotAContainerError) Said() core.Said {
return core.Says("format.NotAContainerWhole", "%s - %s. %s", core.A("What", e.what()), core.A("Why", e.why()), core.A("Fix", e.instead()))
}

// ContentsConflictError is contains stated beside format properties saying the
// same thing. Picking one would build a container holding something other than
// what the recipe says, and the recipe is what somebody reads in a review.
//
// It speaks of files rather than of an archive since a mail message became a
// container (review of #173).
type ContentsConflictError struct {
Format string
Keys []string
}

func (e *ContentsConflictError) Error() string { return e.Said().String() }

// Said is the refusal, for a window that says it in its own language.
func (e *ContentsConflictError) Said() core.Said {
if len(e.Keys) == 1 {
return core.Says("format.ContentsConflictOne",
"%s: contains and the %s property both say which files it holds. Keep contains and drop the property, or the other way round",
core.A("Format", e.Format), core.A("Key", e.Keys[0]))
}
return core.Says("format.ContentsConflict",
"%s: contains and the %s properties both say which files it holds. Keep contains and drop the properties, or the other way round",
core.A("Format", e.Format), core.A("Keys", strings.Join(e.Keys, ", ")))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// NestingUnsupportedError is a container asked to hold its own format.
//
// A legitimate test case that needs a depth limit before it is allowed, and
// there is none yet. It says that rather than pretending the format is unknown.
type NestingUnsupportedError struct {
Format string
}

func (e *NestingUnsupportedError) Error() string { return e.Said().String() }

// Said is the refusal, for a window that says it in its own language.
func (e *NestingUnsupportedError) Said() core.Said {
return core.Says("format.NestingUnsupported",
"%s cannot hold %s yet - a file inside a file of its own format needs a depth limit first. Hold a different format, or make the inner file its own target",
core.A("Format", e.Format), core.A("Inner", e.Format))
}

// Containers lists the formats that accept contains, for a message that tells
// somebody what to write instead.
func Containers() []string {
mu.RLock()
defer mu.RUnlock()
var out []string
for id, d := range registry {
if d.Container != nil {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
1 change: 1 addition & 0 deletions internal/format/csvfile/csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func init() {
ID: "csv",
Name: "Comma-Separated Values",
Extension: ".csv",
MediaType: "text/csv", // IANA, RFC 4180
Fidelity: format.FidelityFull,
Determinism: format.DeterminismByte,

Expand Down
1 change: 1 addition & 0 deletions internal/format/docx/docx.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func init() {
ID: "docx",
Name: "Word (Office Open XML)",
Extension: ".docx",
MediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // IANA
Fidelity: format.FidelityFull,
Determinism: format.DeterminismByte,

Expand Down
Loading
Loading