diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7de7f..c407217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,21 @@ because it turns other people's test suites red. ### Changed +- **A spreadsheet can now be built wider than a spreadsheet can open.** The + `columns` setting of `xlsx` used to stop at 64. It now reaches 32768, which is + the ceiling `csv` already had. + + The number matters because of where a reader stops. Excel and LibreOffice + Calc both hold 16384 columns. Measured with Calc: a sheet of 16384 columns + opens whole, and a sheet of 16385 opens as 16384 - the last column is dropped + and nothing is said about it. At a ceiling of 64 there was no way to build a + file that asks a spreadsheet about its own limit, which is the kind of file + this tool exists to produce. + + `rows` times `columns` still cannot pass 2 million cells, so a sheet 16385 + columns wide holds up to 122 rows. Nothing about a sheet of 64 columns or + fewer changes, and the default is still one column. + - **Byte counts are grouped in threes.** A total used to print as `2516582400 B`. It now prints as `2 516 582 400 B`, in every message that names a number of bytes - `tfg formats`, the summary a run prints, what a diff --git a/internal/format/md/md.go b/internal/format/md/md.go index 25a328a..fc74873 100644 --- a/internal/format/md/md.go +++ b/internal/format/md/md.go @@ -64,7 +64,7 @@ func init() { // Heading depth, table size and which elements appear come later. // Declaring only what is here is what makes a recipe asking for them // fail loudly rather than quietly producing something else. - Properties: textenc.Properties(), + Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM), GeneratorVersion: generatorVersion, Generator: generator{}, }) diff --git a/internal/format/textenc/textenc.go b/internal/format/textenc/textenc.go index c7a6b7f..0c1339b 100644 --- a/internal/format/textenc/textenc.go +++ b/internal/format/textenc/textenc.go @@ -1,10 +1,16 @@ // Package textenc is the character encoding the text formats share. // -// It lives here rather than inside one of them because TXT and MD ask the same -// question and have to answer it in the same words. A copy in each is how +// It lives here rather than inside one of them because TXT, MD and XML ask the +// same question and have to answer it in the same words. A copy in each is how // thirteen packages ended up carrying four different versions of one filler // loop, and the same rule applies to a setting a person reads. // +// Sharing the declaration is not the same as being held to it, and until +// 2026-09-09 only the first was true here. A format that simply did not call +// this package could declare the same setting any way it liked, which is the +// gap Axes and TestEveryTextFormatDeclaresTheEncodingSettingsAsTheyAreDeclared +// Once close between them. +// // What it holds is arithmetic as much as bytes. A file written in UTF-16 is a // whole number of sixteen bit units, so its length is always even - which // means half of all sizes stop being reachable, and the exact size promise @@ -18,6 +24,7 @@ package textenc import ( "fmt" "io" + "sort" "unicode/utf16" "unicode/utf8" @@ -253,19 +260,67 @@ func appendUnit(dst []byte, u uint16, big bool) []byte { return append(dst, byte(u), byte(u>>8)) } -// Properties is the declaration both text formats hand to the registry, so -// the two cannot describe the same setting differently. -func Properties() []format.Property { - return []format.Property{ - { - Name: Setting, Kind: format.PropertyChoice, - Choices: []string{UTF16BE, UTF16LE, UTF8}, Default: UTF8, - Detail: "Which encoding the characters are written in. UTF-16 stores two bytes per character, so a file in it always has an even number of bytes and an odd size is refused.", - }, - { - Name: SettingBOM, Kind: format.PropertyBool, - Default: "false", - Detail: "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file.", - }, +// axes is the declaration of every setting a text format may take, by key. +// +// One copy, so two text formats cannot offer the same setting with a different +// set of values, a different default or a different sentence beside it. That +// is not tidiness waiting for a problem: measured on 2026-09-09 by giving md a +// declaration of its own that agreed on kind, unit and default and offered one +// encoding fewer, eleven guards stayed green while txt and xml wrote utf-16be +// and md refused it. +// +// The default is the one field here that is untouchable rule 3 rather than +// consistency. Every one of these formats has always written UTF-8 with no +// mark, so a format defaulting to anything else moves its own bytes. +var axes = map[string]format.Property{ + Setting: { + Name: Setting, Kind: format.PropertyChoice, + Choices: []string{UTF16BE, UTF16LE, UTF8}, Default: UTF8, + Detail: "Which encoding the characters are written in. UTF-16 stores two bytes per character, so a file in it always has an even number of bytes and an odd size is refused.", + }, + SettingBOM: { + Name: SettingBOM, Kind: format.PropertyBool, + Default: "false", + Detail: "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file.", + }, +} + +// Names is every text encoding setting this build declares, in a stable order. +// +// It exists for the guard that compares what a text format offers against what +// this package says the setting is, so an axis added tomorrow is covered +// without a line changing in that guard. +func Names() []string { + out := make([]string, 0, len(axes)) + for n := range axes { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Axes is the declarations for the settings named, in the order given. +// +// A format lists what it takes rather than receiving all of it, and the three +// that exist today all take both. The narrowing is here because the fourth is +// already named and cannot: an HTML document has to be UTF-8 by its own +// specification, so the only encoding axis it could carry is the mark - and +// without this it would have to write that declaration out by hand, which is +// exactly the drift the guard above exists to refuse. The choice is by NAME +// rather than by value, because no text format wants a narrower set of +// encodings and building for one that might is guessing at a shape. +// +// An unknown name panics rather than being skipped, for the reason archive +// gives: this is called from init, the caller is a programmer, and a silently +// dropped axis is a setting that vanishes from both surfaces with nothing said. +func Axes(names ...string) []format.Property { + out := make([]format.Property, 0, len(names)) + for _, n := range names { + p, ok := axes[n] + if !ok { + panic(fmt.Sprintf("textenc: %q is not a text encoding setting this build declares", n)) + } + out = append(out, p) } + return out } diff --git a/internal/format/txt/txt.go b/internal/format/txt/txt.go index 816bcf4..f8beecc 100644 --- a/internal/format/txt/txt.go +++ b/internal/format/txt/txt.go @@ -57,7 +57,7 @@ func init() { // Line endings and line length come later. Until they do, declaring // only what is here is what makes a recipe asking for them fail loudly // instead of quietly producing something else. - Properties: textenc.Properties(), + Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM), GeneratorVersion: generatorVersion, Generator: generator{}, }) diff --git a/internal/format/xlsx/xlsx.go b/internal/format/xlsx/xlsx.go index 0b40d77..3b0c40f 100644 --- a/internal/format/xlsx/xlsx.go +++ b/internal/format/xlsx/xlsx.go @@ -39,9 +39,23 @@ const ( maxRows = 200_000 minColumns = 1 - // Sixteen thousand three hundred and eighty four is the format's own limit. - // This one is the width a person would actually look at. - maxColumns = 64 + // maxColumns is deliberately ABOVE the width of a spreadsheet, and that is + // the whole reason for the number. It is the same ceiling CSV carries, for + // the same question asked of the same reader. + // + // It said 64 until 2026-09-08, with the reason "the width a person would + // actually look at". That reason describes a document somebody reads, and + // this tool writes fixtures somebody tests with - a ceiling belongs to the + // reader under test, not to our own comfort. At 64 there was no way to + // build a sheet that asks Excel about its own limit at all. + // + // Measured 2026-09-08 with LibreOffice Calc 26.2.5.2 headless, on + // workbooks built outside this tool because this tool could not build + // them: 16384 columns come back whole, and 16385 come back as 16384 with + // the last column dropped, exit 0 and not one word on either stream. That + // silent loss is the thing a tester needs a fixture for, and standing on + // both sides of the line is what a boundary set is. + maxColumns = 32768 // The sheet is held in memory while the package is built, so the pair is // bounded as well as each side. @@ -75,7 +89,7 @@ func init() { Name: "columns", Kind: format.PropertyInt, Min: minColumns, Max: maxColumns, Unit: "columns", Default: "1", - Detail: "How many columns each row has.", + Detail: "How many columns each row has. Above 16384 a spreadsheet may show only the first 16384 and drop the rest without a word.", }, }, JointLimits: []format.JointLimit{{ diff --git a/internal/format/xmlfile/xml.go b/internal/format/xmlfile/xml.go index fa6f71f..3b1550d 100644 --- a/internal/format/xmlfile/xml.go +++ b/internal/format/xmlfile/xml.go @@ -109,7 +109,7 @@ func init() { // Depth, element counts, namespaces, CDATA and an internal DTD come // later. Declaring only what is here is what makes a recipe asking for // them fail loudly instead of quietly producing something else. - Properties: textenc.Properties(), + Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM), GeneratorVersion: generatorVersion, Generator: generator{}, }) diff --git a/internal/guard/columnceiling_test.go b/internal/guard/columnceiling_test.go new file mode 100644 index 0000000..182ed85 --- /dev/null +++ b/internal/guard/columnceiling_test.go @@ -0,0 +1,79 @@ +package guard + +import ( + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// spreadsheetWidth is where a spreadsheet stops, measured rather than +// remembered: 2026-09-03 for CSV and again 2026-09-08 for XLSX, both with +// LibreOffice Calc 26.2.5.2 headless. A table of this many columns comes back +// whole. One column more comes back with this many, the last column dropped, +// exit 0 and not one word on either stream. +const spreadsheetWidth = 16384 + +// narrowerOnPurpose names a format whose columns cannot reach past a +// spreadsheet because the format itself stops first, with the reason. +// +// It is empty, and that is the point rather than an oversight. A format ends up +// here only when its OWN structure caps it - the way an icon stores each side +// in a single byte - and never because a smaller number felt tidier. Writing +// the reason down is the price of the exception, which is what stops this +// becoming the sort of list somebody adds to instead of thinking. +var narrowerOnPurpose = map[string]string{} + +// A format with columns has to offer more of them than a spreadsheet accepts. +// +// The ceiling of a setting belongs to the reader under test, not to what this +// tool finds comfortable to write. Standing on both sides of a limit is what a +// boundary set is for, so a ceiling that stops at the limit offers the last +// table that survives and never the first that does not. +// +// This is not hypothetical tidiness, it is a defect this project shipped. +// XLSX declared 64 from the day it was written until 2026-09-08, with the +// reason "the width a person would actually look at" - which describes a +// document somebody reads rather than a fixture somebody tests with. CSV asked +// the same question of the same reader and answered 32768. Nothing compared +// them, and at 64 there was no way to build a sheet that asks Excel about its +// own limit at all. +// +// What it asks about is the OFFER, because that is what a ceiling is. That the +// file itself works was measured on 2026-09-08 rather than asserted here: a +// workbook of 16385 columns built by this tool converts through Calc at exit 0 +// and comes back with 16384, which is the same silent loss a workbook from +// another writer produces. Building one costs twelve megabytes, which is not a +// price worth paying on every run of this suite. +// +// Asked of every registered format rather than of the two that have the +// setting today, so a third tabular format is covered on the day it arrives. +func TestAFormatWithColumnsReachesPastWhatASpreadsheetAccepts(t *testing.T) { + examined := 0 + + for _, d := range format.All() { + for _, p := range d.Properties { + if p.Name != "columns" || p.Kind != format.PropertyInt { + continue + } + if why, narrow := narrowerOnPurpose[d.ID]; narrow { + if p.Max > spreadsheetWidth { + t.Errorf("%s is excused as narrower on purpose (%s) and reaches %d anyway - take the excuse off", + d.ID, why, p.Max) + } + continue + } + examined++ + if p.Max <= spreadsheetWidth { + t.Errorf("%s offers at most %d columns and a spreadsheet accepts %d, "+ + "so no fixture built from it can ask the reader about its own limit - "+ + "raise the ceiling, or name the structural reason in narrowerOnPurpose", + d.ID, p.Max, spreadsheetWidth) + } + } + } + + if examined == 0 { + t.Fatal("no format offers a column count, so this proved nothing") + } +} diff --git a/internal/guard/familyaxes_test.go b/internal/guard/familyaxes_test.go new file mode 100644 index 0000000..48236f6 --- /dev/null +++ b/internal/guard/familyaxes_test.go @@ -0,0 +1,135 @@ +package guard + +import ( + "reflect" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/format/imagedim" + "github.com/donislawdev/TestingFilesGenerator/internal/format/textenc" +) + +// Two families of formats share a declaration through a package, and this is +// what stops a member writing its own copy instead. +// +// The containers have had this since 2026-09-01 - archiveaxes_test.go compares +// every axis a container offers against the one archive declares. The other two +// families had nothing, and the gap was measured rather than suspected. On +// 2026-09-09 md was given a declaration of its own that agreed on kind, unit +// and default and offered one encoding fewer, and bmp was given one whose width +// started at zero. ELEVEN guards stayed green on the first and TEN on the +// second, including the ones that look closest: TestOneSettingNameMeansOneKind +// OfSetting asks only about Kind and Unit, and the README and format-document +// guards compare the prose against whatever the registry happens to say. +// +// What each of the two ran into is different, and both are worth naming: +// +// - md kept writing utf-16be perfectly well and stopped OFFERING it, so txt +// and xml answered one question and md answered another. +// - bmp printed "whole number of pixels from 0 to 20000" and refused width=0 +// with "it has to be between 1 and 20000". The declaration contradicted the +// program, and what caught it was the backstop in imagedim.Value - the one +// picturesides_test.go says a person cannot reach. A person reaches it +// exactly when a declaration drifts, which is the case with no guard. +// +// Both guards skip a name listed in deliberateHomonyms, so the one list this +// package already keeps stays the one place a shared name can be excused. A +// second list of excuses beside it would be a second place to look away from. +// +// The cost of sharing that list is worth stating rather than leaving to be +// found: an entry there turns the name off for EVERY format, not only for the +// one it excuses, so writing "encoding" into it would silence half of the first +// guard. That is a deliberate act and a visible one - the entry is in the diff, +// and TestEveryDeliberateHomonymStillNamesTwoMeanings makes it prove that two +// formats really do mean different things by the name - but it is a door, and +// archiveaxes_test.go has none because it keeps no list at all. Narrowing the +// skip to the one format that earned it would mean changing the shape of +// deliberateHomonyms, which is another guard's, so it is written down here +// instead of done quietly. + +// Every text format declares the encoding settings the way textenc declares +// them. +// +// Asked by name against textenc.Names(), so a third axis added to that package +// is covered on the day it arrives rather than the day somebody remembers this +// file. A format is free not to carry an axis at all - that is what +// textenc.Axes narrows, and HTML is the named case, since its specification +// leaves it no encoding to choose and only the mark to declare. What is +// refused is carrying one and describing it differently. +func TestEveryTextFormatDeclaresTheEncodingSettingsAsTheyAreDeclaredOnce(t *testing.T) { + compared := 0 + for _, d := range format.All() { + declared := byName(d.Properties) + for _, axis := range textenc.Names() { + if _, excused := deliberateHomonyms[axis]; excused { + continue + } + p, offered := declared[axis] + if !offered { + continue + } + compared++ + want := textenc.Axes(axis)[0] + if !reflect.DeepEqual(p, want) { + t.Errorf("%s declares %q its own way rather than the way textenc declares it\n"+ + " format: %+v\n"+ + " shared: %+v", + d.ID, axis, p, want) + } + } + } + + if compared == 0 { + t.Fatal("no format declares a text encoding setting, so this proved nothing") + } + t.Logf("%d text encoding declaration(s) compared against the one definition", compared) +} + +// Every picture format declares its sides the way imagedim would build them. +// +// This one cannot be a straight comparison, because three of the fields are the +// format's own and are SUPPOSED to differ: the largest side is the ceiling of +// the thing itself, the default is declared by SVG alone, and the sentence is +// per format on purpose - four of the ten are correctly different, and a shared +// one would have made three of them wrong. +// +// So it asks the question the other way round: rebuild the declaration from the +// parts a format supplies and require the result to be what is registered. +// Whatever the format did NOT supply - the name, that it is a whole number, +// that the smallest side is one pixel, that the number counts pixels - has to +// come out of the package, and anything a format added by hand shows up as a +// difference. That covers a field nobody has thought of yet, which a list of +// four field comparisons would not. +func TestEveryPictureFormatDeclaresItsSidesAsTheImageDimensionPackageWould(t *testing.T) { + rebuild := map[string]func(imagedim.Side) format.Property{ + imagedim.SettingWidth: imagedim.Width, + imagedim.SettingHeight: imagedim.Height, + } + + compared := 0 + for _, d := range format.All() { + for _, p := range d.Properties { + build, isSide := rebuild[p.Name] + if !isSide { + continue + } + if _, excused := deliberateHomonyms[p.Name]; excused { + continue + } + compared++ + want := build(imagedim.Side{Largest: p.Max, Default: p.Default, Detail: p.Detail}) + if !reflect.DeepEqual(p, want) { + t.Errorf("%s declares %q its own way rather than through imagedim\n"+ + " format: %+v\n"+ + " the package: %+v", + d.ID, p.Name, p, want) + } + } + } + + if compared == 0 { + t.Fatal("no format declares a picture side, so this proved nothing") + } + t.Logf("%d picture side declaration(s) rebuilt from what the format supplies", compared) +} diff --git a/internal/guard/picturesides_test.go b/internal/guard/picturesides_test.go index 3ea625f..3fac08c 100644 --- a/internal/guard/picturesides_test.go +++ b/internal/guard/picturesides_test.go @@ -29,6 +29,16 @@ import ( // // What it does NOT claim is that a person can reach these. A person cannot, and // the guards on the declaration are what prove the sentence they do get. +// +// That sentence was measured on 2026-09-09 and it holds, but only just, and the +// reason is worth carrying: a person reaches this backstop the moment a +// declaration DRIFTS from what imagedim would build. Given a width starting at +// zero, bmp printed "whole number of pixels from 0 to 20000" and this function +// is what refused width=0, with wording contradicting the print - and ten +// guards stayed green. The door was closed the same day by +// TestEveryPictureFormatDeclaresItsSidesAsTheImageDimensionPackageWould, so +// "a person cannot" is true because something now holds it true rather than +// because nothing had tried. func TestThePictureSideBackstopRefusesWhatTheDeclarationWould(t *testing.T) { const largest = 256 diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 395cb95..22cc6cc 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -623,7 +623,7 @@
columnsxmlcolumnsxml