From 1aecaade6161c2605f138b62a157db8aecc40116 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 24 Sep 2026 08:15:22 +0200 Subject: [PATCH 1/5] guard: every preset's expansion is pinned, not only size-boundaries TestEjectingAPresetGivesTheBytesItAlwaysGave pinned one case of one preset. The next change is to how upload-validation, tabular-import, text-encoding and empty-and-minimal work their sets out, and none of their bytes had a guard. Nineteen cases now, measured on the tree before that change: every preset, other formats in allow, and three refusals whose words carry a format's smallest size. Co-Authored-By: Claude Opus 5.5 --- internal/guard/presetbytes_test.go | 58 ++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index 0a26aa1..1852b3e 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -23,29 +23,60 @@ import ( // was taken by hand on 2026-09-08 and again either side of that move on // 2026-09-22, both times 1298 B and this sum. This is that measurement kept. // +// Every preset since 2026-09-24, and a refusal's words as well as a source's +// bytes. Until then only size-boundaries was pinned, and the change that day +// was to how the other four work their sets out: asking the format for its +// smallest size once rather than for every file, and planning a file the set +// holds twelve times once (docs/GUI-MEMORY-2026-09-23.md section 4j). Neither +// may move a byte, so the gate came first and was measured on the tree before +// the change. The cases are the ones that reach what changed - other formats +// in allow, which ask for other floors, a limit small enough that a refusal +// names the floor, and a spread narrow enough to reach it. +// // What to do when it goes red: decide, rather than update. The sum moving is a // breaking change under D11 - a major, a Breaking entry in the changelog, and // the owner's decision, because untouchable rule 12 says the assistant does not // raise the version. A refactor that moved it is a refactor to undo. func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { + // bytes and sum are the whole document, refused the whole refusal. The + // first row was measured 2026-09-08, the rest on 2026-09-24 before the + // change they guard. pinned := []struct { - id string - args preset.Args - // bytes and sum are the whole document, measured 2026-09-08 and - // unchanged since. - bytes int - sum string + id string + args preset.Args + bytes int + sum string + refused string }{ - { - id: "size-boundaries", - args: preset.Args{"limit": "10mb", "format": "pdf"}, - bytes: 1298, - sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb", - }, + {id: "size-boundaries", args: preset.Args{"format": "pdf", "limit": "10mb"}, bytes: 1298, sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb"}, + {id: "size-boundaries", args: preset.Args{"format": "png", "limit": "1mb"}, refused: "the preset size-boundaries cannot build this set - under_1mb would be 0 B, and a file cannot be smaller than nothing. Raise the limit above 1048650 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 1048576 B."}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "10mb"}, bytes: 1298, sum: "29c7e0a133fb97fdf9d19fb40d0d96ad97c4a1fef67556a9dc734b520f7d9b09"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "2kb", "spread": "1kb"}, bytes: 640, sum: "1346f4a7ae514fe2d15de426b910e308442f66fa97b485932d8fa1f5df56b910"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "300", "spread": "100"}, refused: "the preset size-boundaries cannot build this set - under_100 would be 200 B and the smallest JPG this build makes is 602 B. Raise the limit above 702 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 300 B."}, + {id: "upload-validation", args: preset.Args{}, bytes: 4116, sum: "a75039d859ee25d5ea5fd463ac2a774aacb1c45d013cf7ef3b80d46e0688fb78"}, + {id: "upload-validation", args: preset.Args{"limit": "5mb"}, bytes: 4108, sum: "4b7e716e2e199837b3c2bef228921e0087e894c49a8cf868a246c0ac636b9a35"}, + {id: "upload-validation", args: preset.Args{"limit": "3kb"}, refused: "the preset upload-validation cannot build this set - allowed_pdf would be 1536 B and the smallest PDF this build makes is 3415 B. Raise the limit to 6830 B or more, or take pdf out of the allowed types. The limit asked for was 3072 B."}, + {id: "upload-validation", args: preset.Args{"allow": "docx,gif"}, bytes: 3816, sum: "33975195adf794dd9f6e96dbec8a9775bfbda3c4b7dfd3524475f70c39496059"}, + {id: "upload-validation", args: preset.Args{"allow": "xlsx,ico,wav"}, bytes: 4125, sum: "c46aba3ad1aa6bf3ac2442bde058b9802b4d4f72375fd2f7cbffc6fab1620d6f"}, + {id: "upload-validation", args: preset.Args{"bulk": "3", "far-over": "off"}, bytes: 3933, sum: "98bc51d5edb94a80fb764a03915eccc6997b70fa8eac153578320b3128cb6d95"}, + {id: "upload-validation", args: preset.Args{"deny": "exe,js"}, bytes: 3767, sum: "55d9946e7233761716849f52517cd58f7a38c8748f4cdae8620afade0681621c"}, + {id: "tabular-import", args: preset.Args{}, bytes: 3369, sum: "fad20b41756327a6e85da93715fb09b20db394fb23e4b3ce22bdaf6e11b20726"}, + {id: "tabular-import", args: preset.Args{"rows": "100"}, bytes: 3367, sum: "ee28ae5421d4717fb24ee6dfbef53f7a54e3a00105e5015f884d59768181d552"}, + {id: "tabular-import", args: preset.Args{"columns": "5"}, bytes: 3367, sum: "580d526546720b851ac7d834b97c163f5ff50b29caeff5b27fcc1ba00e5bb952"}, + {id: "text-encoding", args: preset.Args{}, bytes: 4570, sum: "de27b9dc6c646baebaa0b16019ba3ce15d0f1d145941d376b47263de242998e6"}, + {id: "text-encoding", args: preset.Args{"sample": "8kb"}, bytes: 4570, sum: "f87c73864e5f517abb08b50393cd9a1681a90a30560c1d14dfddcf31e8037479"}, + {id: "empty-and-minimal", args: preset.Args{}, bytes: 3811, sum: "80641962ac9dfb303f812fd78e0a0d1080f714094d159d7b10448291debb9279"}, + {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 743, sum: "4fd23e4b06a2e27ede987ab48a2cc302cc2accb9f948c7d5d25f675681f31f69"}, } for _, want := range pinned { expanded, err := preset.Expand(want.id, want.args) + if want.refused != "" { + if err == nil || err.Error() != want.refused { + t.Errorf("%s at %v was refused with\n %q\nuntil now, and now gives\n %v", want.id, want.args, want.refused, err) + } + continue + } if err != nil { t.Errorf("%s refused %v: %v", want.id, want.args, err) continue @@ -55,8 +86,7 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { if len(expanded.Source) == want.bytes && got == want.sum { continue } - t.Errorf("ejecting %s at %v gives %d B and %s, and it has given %d B and %s since "+ - "2026-09-08.\n"+ + t.Errorf("ejecting %s at %v gives %d B and %s, and it has given %d B and %s until now.\n"+ "Every manifest written from this preset carries a hash of these bytes, so this is a "+ "breaking change under D11 rather than a number to update here.\n%s", want.id, want.args, len(expanded.Source), got, want.bytes, want.sum, expanded.Source) From d07059f68f0bf447cbd69ef4acbd5e859b7edc7d Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 24 Sep 2026 08:18:20 +0200 Subject: [PATCH 2/5] preset: a set asks each format for its smallest size once, and plans one file once Expanding upload-validation took 137-142 ms and 57 MB, tabular-import 16-31 ms and 39 MB, every time a value changed - in the window, on every key typed into a preset's setting or into the batch screen with a base. The profile named the cause. A format's smallest size is found by planning it at growing sizes, which for a picture is encoding one, and the presets asked it afresh: sampleFor once for every file about a name or an insides - the same question format.SmallestWithLabel already remembers - and setFile.bytes, the boundary set and the encoding set with settings or a seed, which nothing remembered. And upload-validation plans the same small picture under a dozen names, one encoding per name. - sampleFor asks format.SmallestWithLabel. - format.SmallestRemembered keeps SmallestAccepted by format and request, at most 4096 answers. SmallestWithLabel is that with the label on. - format.RequestKey writes a request as text no other request shares, with the values quoted so that a delimiter of "|" cannot join two settings. - uploadSet.reachable skips a file whose question it has already asked. The same request gets the same refusal and need, and the deepest shortfall keeps the first file that reached it, so no answer moves. The bytes of all nineteen pinned expansions and the words of three refusals are the same as before. A changed value now allocates 9.6 MB and takes 20-36 ms for upload-validation, 19.8 MB for tabular-import. Co-Authored-By: Claude Opus 5.5 --- internal/format/registry.go | 83 +++++++++++++++++++++++++++++---- internal/preset/build.go | 16 +++++-- internal/preset/limitset.go | 2 +- internal/preset/textencoding.go | 4 +- internal/preset/uploadset.go | 22 ++++++++- 5 files changed, 109 insertions(+), 18 deletions(-) diff --git a/internal/format/registry.go b/internal/format/registry.go index bac6893..57b81b3 100644 --- a/internal/format/registry.go +++ b/internal/format/registry.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strconv" + "strings" "sync" ) @@ -49,31 +50,93 @@ func Register(d Descriptor) { // expansion afresh against 0.51 MB remembered, and a keystroke that cost // 380 ms in the real window (docs/GUI-MEMORY-2026-09-23.md section 2.3). // -// Keyed by id, which is safe because Register refuses a second descriptor -// under one. Here rather than beside its caller because this file is already -// where the registry's reads meet its writes: the window settles from its -// worker as well as from its own goroutine. The size is worked out without -// the lock held, because planning an archive reads the registry itself. +// Keyed by id and request - see SmallestRemembered - which is safe because +// Register refuses a second descriptor under one id. Here rather than beside +// its caller because this file is already where the registry's reads meet its +// writes: the window settles from its worker as well as from its own +// goroutine. The size is worked out without the lock held, because planning an +// archive reads the registry itself. func SmallestWithLabel(d Descriptor) int64 { + return SmallestRemembered(d, Request{Label: true}) +} + +// SmallestRemembered is d.SmallestAccepted(r), worked out once per format and +// request and remembered. +// +// SmallestWithLabel's reasoning, for the questions that carry settings or a +// seed. The presets ask the floor of a file with its dialect, of a sheet with +// its rows and columns, of the boundary set with seed 1 - and asked it afresh +// at every expansion, which on a picture is encoding one. Measured 2026-09-23: +// 29% of expanding tabular-import (docs/GUI-MEMORY-2026-09-23.md section 4j). +// +// The size a request asks for and SizeFromContents are left out, because +// SmallestAccepted sets both itself. A request with contents is worked out +// every time - see RequestKey. +// +// At most smallestCeiling answers are kept. The key grows with values somebody +// types - the rows of a sheet - so a long session would otherwise keep one for +// every number ever typed. Past the ceiling the memory starts again, which +// costs one working out per question and nothing else. +func SmallestRemembered(d Descriptor, r Request) int64 { + r.Bytes, r.SizeFromContents = 0, false + key, ok := RequestKey(d.ID, r) + if !ok { + return d.SmallestAccepted(r) + } smallestMu.Lock() - known, ok := smallestKnown[d.ID] + known, found := smallestKnown[key] smallestMu.Unlock() - if ok { + if found { return known } - size := d.SmallestAccepted(Request{Label: true}) + size := d.SmallestAccepted(r) smallestMu.Lock() - smallestKnown[d.ID] = size + if len(smallestKnown) >= smallestCeiling { + smallestKnown = map[string]int64{} + } + smallestKnown[key] = size smallestMu.Unlock() return size } -// smallestKnown is what SmallestWithLabel has worked out, by format id. +// smallestCeiling is how many answers SmallestRemembered keeps - a few hundred +// bytes each, so the most it holds is about a megabyte. +const smallestCeiling = 4096 + +// smallestKnown is what SmallestRemembered has worked out, by RequestKey. var ( smallestMu sync.Mutex smallestKnown = map[string]int64{} ) +// RequestKey is one request to one format written as text that no other +// request shares, so that something worked out for it can be kept under it. +// +// Every field of Request that can change a plan is in it, and a guard sets +// each field in turn to hold that true when Request grows. The values are +// quoted, because a setting's value is text somebody typed and may hold any +// separator - a CSV delimiter of "|" would otherwise read as the start of a +// second setting, and two requests sharing a key share an answer. +// +// A request with contents has no key. What an archive holds is a list of +// formats with sizes of their own, and nothing asks such a request twice. +func RequestKey(id string, r Request) (string, bool) { + if len(r.Contains) > 0 { + return "", false + } + names := make([]string, 0, len(r.Properties)) + for name := range r.Properties { + names = append(names, name) + } + sort.Strings(names) + var b strings.Builder + fmt.Fprintf(&b, "%q %d %t %t %d", id, r.Bytes, r.SizeFromContents, r.Label, r.Seed) + for _, name := range names { + fmt.Fprintf(&b, " %q=%q", name, r.Properties[name]) + } + return b.String(), true +} + // SortChoices puts a closed set in the order somebody looks for a value in. // // Here rather than in the menu that draws them, and that is the whole point: diff --git a/internal/preset/build.go b/internal/preset/build.go index 46eaafb..b62b49a 100644 --- a/internal/preset/build.go +++ b/internal/preset/build.go @@ -160,9 +160,13 @@ type setFile struct { } // bytes is the size this file is asked for. +// +// A floor is the remembered one, because this is asked more than once for +// every file - by refused and by draft - and working it out plans the format +// at growing sizes. See format.SmallestRemembered. func (f setFile) bytes() int64 { if f.atFloor { - return f.desc.SmallestAccepted(format.Request{Label: true, Properties: f.props}) + return format.SmallestRemembered(f.desc, format.Request{Label: true, Properties: f.props}) } return f.size } @@ -174,12 +178,16 @@ func (f setFile) bytes() int64 { // one level down: a set missing the three files the run was about still looks // like a set. func (f setFile) refused() error { - r := format.Request{Label: true, Properties: f.props} - r.Bytes = f.bytes() - _, err := f.desc.Generator.Plan(r) + _, err := f.desc.Generator.Plan(f.request()) return err } +// request is what refused asks the format - one place, so that a set that +// skips a question it has already asked keys it by the question itself. +func (f setFile) request() format.Request { + return format.Request{Label: true, Properties: f.props, Bytes: f.bytes()} +} + func (f setFile) draft() recipe.TargetDraft { count := "1" if f.count > 1 { diff --git a/internal/preset/limitset.go b/internal/preset/limitset.go index 2c3d229..79e8f24 100644 --- a/internal/preset/limitset.go +++ b/internal/preset/limitset.go @@ -83,7 +83,7 @@ func (s limitSet) steps() []step { // files still looks like a set, and the three that are missing are the ones the // run was about - the ones nearest the limit. func (s limitSet) reachable(set []step) error { - floor := s.desc.SmallestAccepted(format.Request{Seed: 1, Label: true}) + floor := format.SmallestRemembered(s.desc, format.Request{Seed: 1, Label: true}) for _, one := range set { if one.size >= floor { continue diff --git a/internal/preset/textencoding.go b/internal/preset/textencoding.go index 983f7a4..be18018 100644 --- a/internal/preset/textencoding.go +++ b/internal/preset/textencoding.go @@ -254,7 +254,7 @@ func lineEndingCells() []textFile { // about the settings and not about the room they need. func refusedOutright(desc format.Descriptor, props map[string]string) string { r := format.Request{Label: true, Properties: props} - r.Bytes = desc.SmallestAccepted(r) + r.Bytes = format.SmallestRemembered(desc, r) _, err := desc.Generator.Plan(r) var bad *format.PropertyValueError if errors.As(err, &bad) { @@ -331,7 +331,7 @@ func roomEnough(files []textFile, size int64) error { if _, err := f.desc.Generator.Plan(r); err == nil { continue } - if need := f.desc.SmallestAccepted(r); need > floor { + if need := format.SmallestRemembered(f.desc, r); need > floor { floor, tallest = need, f } } diff --git a/internal/preset/uploadset.go b/internal/preset/uploadset.go index 4dc37e7..68c5208 100644 --- a/internal/preset/uploadset.go +++ b/internal/preset/uploadset.go @@ -317,7 +317,21 @@ func (s uploadSet) files() []setFile { // file is. func (s uploadSet) reachable(files []setFile) error { var worst shortfall + // A file asking what an earlier one asked is not planned again. The set + // holds one small picture under a dozen names - a name with spaces, one + // with no extension, one outside ASCII - and planning is encoding it, so + // that was a dozen encodings of one question (docs/GUI-MEMORY-2026-09-23.md + // section 4j). Skipping changes no answer: the same request gets the same + // refusal and the same need, and the deepest shortfall keeps the FIRST + // file that reached it, because the comparison below is strict. + asked := map[string]bool{} for _, f := range files { + if key, ok := format.RequestKey(f.desc.ID, f.request()); ok { + if asked[key] { + continue + } + asked[key] = true + } short, err := s.shortfallOf(f) if err != nil { return err @@ -394,8 +408,14 @@ func wouldReach(floor, size, limit int64) int64 { // // The larger of the two rather than the sample, so that a format with a floor // above it cannot turn a file about a NAME into a refusal about a size. +// +// The remembered floor rather than one worked out here. It is the same +// question - the label on, nothing else - and it was asked once for every +// file about a name or an insides, each time encoding pictures to find the +// answer: 35% of expanding upload-validation, measured 2026-09-23 +// (docs/GUI-MEMORY-2026-09-23.md section 4j). func sampleFor(desc format.Descriptor) int64 { - if floor := desc.SmallestAccepted(format.Request{Label: true}); floor > uploadSample { + if floor := format.SmallestWithLabel(desc); floor > uploadSample { return floor } return uploadSample From 18e0b5f8484433a30d4886337b924f7c15aa7403 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 24 Sep 2026 08:22:20 +0200 Subject: [PATCH 3/5] guard: a new value asks each question of a preset's set once, and a request's key holds every field TestANewUploadLimitDoesNotAskTheSameQuestionsAgain and TestANewRowCountWorksTheSheetsSmallestSizeOutOnce hold the allocation of an expansion at a value the process has not seen, least of five, with the line between what was measured with the answers remembered and without. Each first asks that the set still holds what it is about - files asking one question under several names, a sheet at its floor. TestEveryFieldOfARequestIsInItsKey sets every field of format.Request in turn by reflection and wants a key of its own, and wants a value holding a space and an equals sign kept apart from two settings. Co-Authored-By: Claude Opus 5.5 --- internal/guard/presetcost_test.go | 149 ++++++++++++++++++++++++++++++ internal/guard/requestkey_test.go | 77 +++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 internal/guard/presetcost_test.go create mode 100644 internal/guard/requestkey_test.go diff --git a/internal/guard/presetcost_test.go b/internal/guard/presetcost_test.go new file mode 100644 index 0000000..788027c --- /dev/null +++ b/internal/guard/presetcost_test.go @@ -0,0 +1,149 @@ +package guard + +import ( + "fmt" + "runtime" + "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" +) + +// A new upload limit does not ask the same questions again. +// +// Expanding upload-validation took 137-142 ms and 57 MB whenever a value +// changed, which in the window is every key typed into one of its settings or +// into the batch screen built on it. Two causes, both a question asked again +// with the answer already known: the smallest size of a format, found for +// every file about a name by encoding pictures at growing sizes, and one small +// picture planned under a dozen names (docs/GUI-MEMORY-2026-09-23.md section +// 4j). Measured 2026-09-24, least of five: 9.58 MB with both answered once, +// 18.95 MB with the picture planned under every name, 44.29 MB with the floor +// worked out for every file, 9.67 MB under -race. The line sits between the +// first two. +// +// Asked with a limit this process has not expanded before, because the same +// limit twice is the window's memory's business and says nothing about this. +// The least of several readings, because the counter is the whole process's +// and a reading can only be too high - see +// TestTheMinimalSetIsWorkedOutOnceAndNotAtEveryExpansion. +// +// If it goes red after a change that is not about this, measure the four +// numbers above again before moving the line: a line moved to the new reading +// no longer stands between anything. +func TestANewUploadLimitDoesNotAskTheSameQuestionsAgain(t *testing.T) { + const ceiling = 13<<20 + 1<<19 // 13.5 MB + // The state this is about: files that ask one question under several + // names. A set without them would expand cheaply whatever this code did. + if most := mostFilesAskingOneQuestion(t, "upload-validation", preset.Args{}); most < 5 { + t.Fatalf("the most files of upload-validation asking one question is %d, so the set no longer holds what this guard is about", most) + } + least := leastAllocatedByAnExpansion(t, "upload-validation", func(i int) preset.Args { + return preset.Args{"limit": fmt.Sprintf("%dmb", 21+i)} + }) + if least > ceiling { + t.Errorf("expanding upload-validation at a new limit allocated %d bytes, over %d - "+ + "a format's smallest size or a file already planned is being worked out again", least, ceiling) + } +} + +// A new row count works the sheet's smallest size out once, not twice. +// +// tabular-import asks for its sheet at the smallest size the rows and columns +// allow, and finding that size means building the sheet at growing sizes. The +// size was asked twice per expansion - once to check the file, once to write +// it into the set - and nothing remembered it. A new row count does change the +// sheet, so one working out is the real work and the second was the waste. +// The wide CSV, the other file asked at its floor, is the same at every +// expansion and now comes from memory. +// +// Measured 2026-09-24 at 300 rows, least of five: 19.84 MB remembered, 38.59 MB +// worked out twice, 20.63 MB under -race. The line sits between. The numbers +// grow with the rows - 700 rows allocate 39 MB remembered - so the rows asked +// here stay where they were measured. +func TestANewRowCountWorksTheSheetsSmallestSizeOutOnce(t *testing.T) { + const ceiling = 27 << 20 + if sheets := filesOfFormat(t, "tabular-import", preset.Args{}, "xlsx"); sheets != 1 { + t.Fatalf("tabular-import holds %d sheets, so the set no longer holds the file this guard is about", sheets) + } + least := leastAllocatedByAnExpansion(t, "tabular-import", func(i int) preset.Args { + return preset.Args{"rows": fmt.Sprintf("%d", 300+i)} + }) + if least > ceiling { + t.Errorf("expanding tabular-import at a new row count allocated %d bytes, over %d - "+ + "the sheet's smallest size is being worked out more than once", least, ceiling) + } +} + +// leastAllocatedByAnExpansion expands a preset once, then five times with +// values it has not been given, and returns the least any of the five +// allocated. +func leastAllocatedByAnExpansion(t *testing.T, id string, fresh func(int) preset.Args) uint64 { + t.Helper() + if _, err := preset.Expand(id, preset.Args{}); err != nil { + t.Fatalf("%s did not expand, so nothing was asked: %v", id, err) + } + least := ^uint64(0) + for i := 0; i < 5; i++ { + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if _, err := preset.Expand(id, fresh(i)); err != nil { + t.Fatalf("%s at %v: %v", id, fresh(i), err) + } + runtime.ReadMemStats(&after) + if spent := after.TotalAlloc - before.TotalAlloc; spent < least { + least = spent + } + } + return least +} + +// mostFilesAskingOneQuestion is how many targets of an expanded set ask their +// format the same thing, at most - one format, one size, one set of settings. +func mostFilesAskingOneQuestion(t *testing.T, id string, args preset.Args) int { + t.Helper() + same := map[string]int{} + most := 0 + for _, target := range expandedTargets(t, id, args) { + if len(target.Sizes) == 0 { + continue + } + key, ok := format.RequestKey(target.Format, format.Request{ + Label: true, Properties: target.Properties, Bytes: target.Sizes[0], + }) + if !ok { + continue + } + same[key]++ + if same[key] > most { + most = same[key] + } + } + return most +} + +func filesOfFormat(t *testing.T, id string, args preset.Args, formatID string) int { + t.Helper() + n := 0 + for _, target := range expandedTargets(t, id, args) { + if target.Format == formatID { + n++ + } + } + return n +} + +func expandedTargets(t *testing.T, id string, args preset.Args) []recipe.Target { + t.Helper() + expanded, err := preset.Expand(id, args) + if err != nil { + t.Fatalf("%s did not expand: %v", id, err) + } + rec, err := recipe.Parse(expanded.Source, id) + if err != nil { + t.Fatalf("%s expanded into a recipe that does not read: %v", id, err) + } + return rec.Targets +} diff --git a/internal/guard/requestkey_test.go b/internal/guard/requestkey_test.go new file mode 100644 index 0000000..10adb04 --- /dev/null +++ b/internal/guard/requestkey_test.go @@ -0,0 +1,77 @@ +package guard + +import ( + "reflect" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// Two requests that can be planned differently never share a key. +// +// format.RequestKey keys what was worked out for a request - the smallest size +// a format takes, whether a file of a set was already planned. Two requests +// under one key share one answer, so a field of Request left out of the key is +// a preset laid out on another request's floor, and a manifest carrying a hash +// of bytes that should not have been. Nothing would say so: the answer is a +// number, and a wrong number looks like a right one. +// +// Asked of every field of Request by reflection rather than of a list written +// here, so the day Request grows a field, this goes red until the key carries +// it. A list copied by hand is the kind that goes stale green. +func TestEveryFieldOfARequestIsInItsKey(t *testing.T) { + base, ok := format.RequestKey("png", format.Request{}) + if !ok { + t.Fatal("an empty request has no key, so nothing below compares against anything") + } + typ := reflect.TypeOf(format.Request{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + var r format.Request + v := reflect.ValueOf(&r).Elem().Field(i) + switch field.Type.Kind() { + case reflect.Int64: + v.SetInt(1) + case reflect.Uint64: + v.SetUint(1) + case reflect.Bool: + v.SetBool(true) + case reflect.Map: + v.Set(reflect.ValueOf(map[string]string{"delimiter": ";"})) + case reflect.Slice: + v.Set(reflect.MakeSlice(field.Type, 1, 1)) + // A request with contents is worked out every time rather than + // keyed, and that has to stay true rather than become a key that + // ignores them. + if _, keyed := format.RequestKey("png", r); keyed { + t.Errorf("a request with %s set has a key, and the key cannot tell what is inside one archive from another", field.Name) + } + continue + default: + t.Fatalf("Request.%s is a %s, which this guard does not know how to set - teach it, and the key, before anything is keyed by it", + field.Name, field.Type.Kind()) + } + got, keyed := format.RequestKey("png", r) + if !keyed { + t.Errorf("a request with only %s set has no key", field.Name) + continue + } + if got == base { + t.Errorf("a request with %s set has the key of an empty one, so the two would share an answer", field.Name) + } + } + + // The format as well, which is not a field. + if other, _ := format.RequestKey("jpg", format.Request{}); other == base { + t.Error("two formats asked the same thing share a key") + } + + // And a value holding what the key separates settings with - a space and + // an equals sign. A value is text somebody typed, so it can hold anything, + // and quoting is what keeps one setting from reading as two. + two, _ := format.RequestKey("csv", format.Request{Properties: map[string]string{"a": "x", "b": "y"}}) + one, _ := format.RequestKey("csv", format.Request{Properties: map[string]string{"a": "x b=y"}}) + if two == one { + t.Errorf("one setting whose value holds the separators has the key of two settings: %s", one) + } +} From 0815238bbd7ce07e538b43865796a5b39c88f19a Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 24 Sep 2026 08:29:17 +0200 Subject: [PATCH 4/5] preset: the question already asked is told apart in a function of its own, so reachable stays two deep Co-Authored-By: Claude Opus 5.5 --- internal/preset/uploadset.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/preset/uploadset.go b/internal/preset/uploadset.go index 68c5208..096ca9c 100644 --- a/internal/preset/uploadset.go +++ b/internal/preset/uploadset.go @@ -326,11 +326,8 @@ func (s uploadSet) reachable(files []setFile) error { // file that reached it, because the comparison below is strict. asked := map[string]bool{} for _, f := range files { - if key, ok := format.RequestKey(f.desc.ID, f.request()); ok { - if asked[key] { - continue - } - asked[key] = true + if !firstTimeAsked(asked, f) { + continue } short, err := s.shortfallOf(f) if err != nil { @@ -350,6 +347,21 @@ func (s uploadSet) reachable(files []setFile) error { return s.cannotReach(worst) } +// firstTimeAsked says whether a set is asking this file's question for the +// first time, and remembers that it now has. A question with no key - a file +// with contents - counts as asked for the first time, every time. +func firstTimeAsked(asked map[string]bool, f setFile) bool { + key, ok := format.RequestKey(f.desc.ID, f.request()) + if !ok { + return true + } + if asked[key] { + return false + } + asked[key] = true + return true +} + // shortfall is one file that is smaller than its format will write, and the // limit at which it would stop being. type shortfall struct { From 96cc12bc263693c298a450822728f6a133a7b948 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 24 Sep 2026 08:41:09 +0200 Subject: [PATCH 5/5] changelog: changing a setting of a preset is about four times faster Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1438131..ddb5031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -494,6 +494,13 @@ because it turns other people's test suites red. again for each one. Both are now done once, and the window uses less memory while you type. +- **Changing a setting of a preset is about four times faster.** With + `upload-validation`, changing one of its settings held the window for + about 0.15 seconds, and so did every key typed on `Several batches` built + on it. Both now take about 0.03 seconds. Switching the base preset on + `Several batches` to `upload-validation` went from about 0.3 to 0.07 + seconds. The sets the presets build are byte for byte the same as before. + - **Typing on the `Presets` screen no longer lags.** With `upload-validation` chosen, every key typed into a box held the window for about 0.3 seconds, and about 0.07 seconds with `tabular-import`, because the preset was worked