diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a15f917..9b7ef2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -600,7 +600,7 @@ jobs: # in somebody else's file. run: | set -euo pipefail - watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod' + watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod' # On a pull request there is no "before" - the field belongs to a push # - so this asked for something empty and every pull request answered # "touched". That quietly undid the decision of 2026-08-20, because diff --git a/CHANGELOG.md b/CHANGELOG.md index c407217..13c4d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,44 @@ because it turns other people's test suites red. ### Added +- **Files can be broken on purpose.** A target takes `damage`, and the files it + produces are ones a reader refuses: + + ```yaml + targets: + - id: broken-uploads + format: png + size: 20kb + count: 10 + damage: [zero-head] + ``` + + or from the command line, repeatable and applied in the order given: + + ``` + tfg generate --format png --size 20kb --damage zero-head:bytes=16 + ``` + + Until now every file this tool wrote was well formed, so the third question + an upload validator asks - "does it open" - was one nothing here could put to + it. + + The file still comes out **exactly** the size you asked for. What changes is + the content, and the manifest records what was done to it and says the file + is expected to be rejected. + + There is one damage in this release, `zero-head`, which overwrites the + opening bytes with zeros. It was chosen because all twenty four formats have + something that refuses the result - measured, not assumed - and because it + does not change the length. `tfg formats` is unchanged and no existing file + moves a byte: a run that does not ask for damage goes down the path it always + did. + + Two things it refuses rather than doing quietly. A file smaller than the + damage is refused before anything is written, naming a size that would work. + And a damage that would leave the bytes untouched stops the run, because a + whole file described as broken is worse than no file at all. + - **HTML files can be a fragment instead of a whole page.** A new setting on `html`: `structure`, which takes `document` or `fragment`. It defaults to the whole page these files have always been, so a recipe that says nothing gets diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9ebdd14..16cdceb 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "strings" "syscall" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" @@ -268,6 +269,70 @@ func (p propertyFlag) Set(v string) error { return nil } +// repeatedFlags are the flags a person may write more than once. +// +// One piece rather than two fields on the options struct, because the type was +// at the crowding band and the answer to that is to move state out. They belong +// together anyway: both map onto a block of a recipe rather than onto a single +// line of one. +type repeatedFlags struct { + props propertyFlag + damage damageFlag +} + +// damageFlag collects repeated --damage entries, in the order they were given. +// +// A list rather than a map, which is the difference from --set beside it: +// order is part of what a chain of damages means, and the same damage twice +// with different settings is a legitimate thing to ask for. --set refuses a +// repeat because one of two values would be lost silently, and here neither is. +// +// The settings ride after a colon so one entry stays one argument: +// +// --damage zero-head +// --damage zero-head:bytes=16 +// --damage zero-head:bytes=16,other=2 +type damageFlag struct{ chain damage.Chain } + +func (d *damageFlag) String() string { return d.chain.String() } + +func (d *damageFlag) Set(v string) error { + id, settings, hasSettings := strings.Cut(v, ":") + if id == "" { + return fmt.Errorf("expected the name of a damage, got %q", v) + } + values, err := damageSettings(id, settings, hasSettings) + if err != nil { + return err + } + d.chain = append(d.chain, damage.Spec{ID: id, Values: values}) + return nil +} + +// damageSettings reads the name=value pairs after the colon. +// +// Its own function rather than a block inside Set, because the shape gates +// count how deep a reader has to follow and this was the third level. +func damageSettings(id, settings string, stated bool) (damage.Values, error) { + values := damage.Values{} + if !stated { + return values, nil + } + for _, pair := range strings.Split(settings, ",") { + key, value, found := strings.Cut(pair, "=") + if !found || key == "" { + return nil, fmt.Errorf("expected name=value after the colon, got %q", pair) + } + if _, exists := values[key]; exists { + // The same reason --set gives: one of the two would be lost and + // nobody would know which. + return nil, fmt.Errorf("%s is set more than once on %s", key, id) + } + values[key] = value + } + return values, nil +} + // args2 rebuilds the command as it would have to be typed to run again. // // It goes into the manifest, where its whole job is to be re-runnable, and it diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 7c84f06..a0c8eb4 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -12,6 +12,7 @@ import ( "syscall" "github.com/donislawdev/TestingFilesGenerator/internal/audit" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/manifest" @@ -201,6 +202,21 @@ func classifyRequest(err error) (int, bool) { if errors.As(err, &unknownPreset) { return ExitUsage, true } + // A damage this build does not know is a typo in the invocation, the same + // class as an unknown preset - a recipe naming one is refused while the + // recipe is read, so anything reaching here came off the command line. + var unknownDamage *damage.UnknownError + if errors.As(err, &unknownDamage) { + return ExitUsage, true + } + // A file smaller than the damage it was given is the same class as a size + // below a format's minimum: the request is well formed and nothing here can + // deliver it. It gets the same code for that reason rather than by + // resemblance. + var tooSmall *damage.TooSmallError + if errors.As(err, &tooSmall) { + return ExitFormat, true + } if code, ok := classifyFormat(err); ok { return code, true } diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 45abb57..cef196f 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/manifest" @@ -37,7 +38,11 @@ type generateOpts struct { clean bool dryRun bool asJSON bool - props propertyFlag + // The flags a person may write more than once, in one piece rather than + // two fields. They are one thing on the screen and one thing in a recipe - + // what was stated repeatedly - and grouping them is what moving state out + // means when a type is at the crowding band. + repeated repeatedFlags } func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.Writer)) { @@ -65,8 +70,19 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io. // Twenty five formats with a dozen properties each would give a surface // nobody reads in --help, and this maps one to one onto the properties // block of a recipe, so both surfaces speak the same words. - g.props = propertyFlag{} - fs.Var(&g.props, "set", "format property, repeatable: --set width=1920 --set height=1080") + g.repeated.props = propertyFlag{} + fs.Var(&g.repeated.props, "set", "format property, repeatable: --set width=1920 --set height=1080") + + // Repeatable like --set, and for the same reason, but a list rather than a + // map: the order damages are applied in is part of what they mean. + // The names come from the registry rather than being typed here. The first + // version of this line ended "run tfg damage to see what there is" and + // there is no such command - a sentence in shipped help promising + // something that does not exist, which is the class this project calls + // prose with an expiry date. Built from Names() it cannot say that again. + fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+ + "--damage zero-head, or --damage zero-head:bytes=16. This build has: "+ + strings.Join(damage.Names(), ", ")) usage := func(w io.Writer) { fmt.Fprint(w, `tfg generate - produce files. @@ -312,15 +328,14 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer) ID: g.id, Format: g.formatID, Sizes: sizes, - SizeIsRange: g.sizeRange != "", - SizeMin: rangeLow, - SizeMax: rangeHigh, + Range: engine.SizeRange{Used: g.sizeRange != "", Min: rangeLow, Max: rangeHigh}, BoundaryLimit: boundaryLimit, NameTmpl: g.name, Label: !g.clean, Expected: g.expected, ExpectedReason: g.expectedReason, - Properties: g.props, + Properties: g.repeated.props, + Damage: g.repeated.damage.chain, }}, ExitOK } @@ -599,9 +614,7 @@ func engineTarget(t recipe.Target, label bool) engine.Target { Sizes: t.Sizes, Contains: contentsOf(t), SizeFromContents: t.SizeFromContents, - SizeIsRange: t.SizeIsRange, - SizeMin: t.SizeMin, - SizeMax: t.SizeMax, + Range: engine.SizeRange(t.Range), BoundaryLimit: t.BoundaryLimit, NameTmpl: t.Name, Label: label, @@ -609,6 +622,7 @@ func engineTarget(t recipe.Target, label bool) engine.Target { ExpectedReason: t.ExpectedReason, Group: t.Group, Properties: t.Properties, + Damage: t.Damage, } } diff --git a/internal/damage/apply.go b/internal/damage/apply.go new file mode 100644 index 0000000..9203909 --- /dev/null +++ b/internal/damage/apply.go @@ -0,0 +1,244 @@ +package damage + +import ( + "fmt" + "io" + "sort" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// Defaults are the declared defaults, for the values a caller left out. +func (d Descriptor) Defaults() Values { + out := Values{} + for _, p := range d.Parameters { + if p.Default != "" { + out[p.Name] = p.Default + } + } + return out +} + +// ParameterNames is what this damage takes, in the declared order. +func (d Descriptor) ParameterNames() []string { + out := make([]string, 0, len(d.Parameters)) + for _, p := range d.Parameters { + out = append(out, p.Name) + } + return out +} + +// CheckEach is every problem with the values stated, in a stable order. +// +// All of them rather than the first, because RC7 says a recipe comes back with +// everything wrong with it. The wording is not written here: Allows and Instead +// are methods on the declaration, so a damage parameter and a format setting +// refuse in the same voice without either copying the other's sentences. +// +// The refusal type is format.PropertyValueError, whose first field is called +// Format and here holds the id of a damage. The field name is narrow and the +// type is right - it is the one refusal in this tree that a form can take +// apart by name - so it is used as it is and the narrowness is written down +// rather than fixed by a rename that would touch every format. +func (d Descriptor) CheckEach(v Values) []error { + declared := make(map[string]format.Property, len(d.Parameters)) + for _, p := range d.Parameters { + declared[p.Name] = p + } + + stated := make([]string, 0, len(v)) + for name := range v { + stated = append(stated, name) + } + sort.Strings(stated) + + var bad []error + for _, name := range stated { + p, known := declared[name] + if !known { + bad = append(bad, &format.UnknownPropertyError{ + Format: d.ID, Key: name, Known: d.ParameterNames(), + }) + continue + } + if wrong := d.checkOne(p, v[name]); wrong != nil { + bad = append(bad, wrong) + } + } + return bad +} + +// checkOne is one stated value against one declaration. +// +// Lifted out of the loop above rather than nested inside it, because the shape +// gates count how deep a reader has to follow and three was the line. It is +// also the whole of what "is this value allowed" means here, which is a better +// reason to have it than the count. +// +// An empty value means "not stated", the same as leaving it out - that is what +// an unset flag and an empty recipe entry both look like by the time they +// arrive here. +func (d Descriptor) checkOne(p format.Property, raw string) error { + if raw == "" { + return nil + } + why := p.Allows(raw) + if why == "" { + return nil + } + return &format.PropertyValueError{ + Format: d.ID, Key: p.Name, Value: raw, Reason: why, Remedy: p.Instead(), + } +} + +// Check is the first problem with the values stated, for the one-target path +// from the command line flags where there is no form to lay four of them out +// on. +func (d Descriptor) Check(v Values) error { + if bad := d.CheckEach(v); len(bad) > 0 { + return bad[0] + } + return nil +} + +// Chain is a list of damages in the order they are applied. +// +// A list from the first day rather than a single value, because composition is +// a requirement rather than an extension, and because the shape has to carry it +// before anything is written against the contract. Order is significant and +// recorded: truncating after appending a tail removes what was appended, so the +// same two entries in the other order are different bytes, and D11 promises +// those bytes do not move. +type Chain []Spec + +// Floor is the smallest file every damage in the chain can be applied to. +// +// The largest of the floors rather than their sum: each damage is applied to +// the whole file that reaches it, so a chain is bounded by its most demanding +// member. That is true for length preserving damage, which is all this build +// has - a length CHANGING damage makes the later members see a different file, +// and that arithmetic arrives with truncate rather than being guessed at here. +func (c Chain) Floor() (int64, string, error) { + var floor int64 + var owner string + for _, s := range c { + d, err := Get(s.ID) + if err != nil { + return 0, "", err + } + if f := d.Floor(withDefaults(d, s.Values)); f > floor { + floor, owner = f, s.ID + } + } + return floor, owner, nil +} + +// Open builds the chain as one writer, with the first damage of the list +// outermost so that the bytes meet them in the order written. +// +// Returns the streams as well, because whether each one changed anything is a +// question asked per damage rather than once at the end - two damages can +// cancel out, and a chain whose second member was idle would otherwise pass. +func (c Chain) Open(out io.Writer) (io.Writer, []Stream, error) { + w := out + streams := make([]Stream, 0, len(c)) + // Built back to front so that the first entry ends up outermost. + for i := len(c) - 1; i >= 0; i-- { + d, err := Get(c[i].ID) + if err != nil { + return nil, nil, err + } + s, err := d.Open(withDefaults(d, c[i].Values), w) + if err != nil { + return nil, nil, err + } + streams = append(streams, s) + w = s + } + // Back into the order of the list, so a caller reporting on stream i is + // reporting on entry i. + for l, r := 0, len(streams)-1; l < r; l, r = l+1, r-1 { + streams[l], streams[r] = streams[r], streams[l] + } + return w, streams, nil +} + +// Idle is the first damage of the chain that changed nothing, by name, or an +// empty string when every one of them moved a byte. +func (c Chain) Idle(streams []Stream) string { + for i, s := range streams { + if i < len(c) && !s.Touched() { + return c[i].ID + } + } + return "" +} + +// withDefaults fills in what the caller left out, so a damage never has to ask +// twice whether a value was stated. +func withDefaults(d Descriptor, v Values) Values { + out := d.Defaults() + for name, value := range v { + if value != "" { + out[name] = value + } + } + return out +} + +// String is the chain as a person writes it, for a message and for the record +// of the command a run was made with. +func (c Chain) String() string { + out := make([]string, 0, len(c)) + for _, s := range c { + out = append(out, s.String()) + } + return fmt.Sprint(out) +} + +// Resolved is the settings this entry was applied with, defaults filled in. +// +// Filled in rather than left as written, because the manifest answers "what +// was done to this file" and a reader of it should not have to know what the +// default was in the build that wrote it. Nil when the damage takes no +// settings, so the key is absent rather than empty. +func (s Spec) Resolved() map[string]string { + d, err := Get(s.ID) + if err != nil { + // A damage the registry does not know cannot reach here through either + // surface - the recipe refuses it and the flags refuse it. Returning + // what was stated beats returning nothing, because a record of an + // impossible state should still say what it saw. + if len(s.Values) == 0 { + return nil + } + return s.Values + } + out := withDefaults(d, s.Values) + if len(out) == 0 { + return nil + } + return out +} + +// String is one entry as a person writes it on the command line. +func (s Spec) String() string { + if len(s.Values) == 0 { + return s.ID + } + names := make([]string, 0, len(s.Values)) + for name := range s.Values { + names = append(names, name) + } + sort.Strings(names) + + out := s.ID + for i, name := range names { + sep := "," + if i == 0 { + sep = ":" + } + out += sep + name + "=" + s.Values[name] + } + return out +} diff --git a/internal/damage/damage.go b/internal/damage/damage.go new file mode 100644 index 0000000..0c6641a --- /dev/null +++ b/internal/damage/damage.go @@ -0,0 +1,186 @@ +// Package damage is what a file can be broken with, and the registry holding +// the kinds this build knows. +// +// The tool answers questions about SIZE and about NAME. It does not answer the +// question about bad CONTENT, and an upload validator asks three things: the +// size, the type, and whether the file opens. The third is unreachable here, +// because every file this tool writes is well formed by definition - the +// oracles see to that. Damage is the axis that makes the third question +// askable. +// +// Why it is an axis of the TARGET rather than a property of a format: a +// property is declared per format, so "corrupt" as a property would be a +// hundred declarations of one thing, a hundred registry tests, and a hundred +// places to forget. As an axis it works for a format added tomorrow without +// that format being touched. Measured on 2026-09-08 across 24 formats: five +// format agnostic damages produce 86 testable pairs with zero lines of code +// per format. docs/CORRUPTION-ARCHITECTURE-2026-09-08.md carries the numbers. +// +// What this package does NOT do is decide whether a damaged file is worth +// writing. That is the witness rule - a pair with no judge able to refuse the +// result is a pair this tool does not offer - and it belongs with the oracles, +// because it is a measurement rather than a declaration. +package damage + +import ( + "fmt" + "io" + "sort" + "sync" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// Values are the parameter values of one damage, as a recipe or a flag wrote +// them. +// +// Strings rather than a typed struct, and the same shape a format's properties +// arrive in, because it is the same question asked of a different thing: the +// declaration says what a value may be, and one piece of code answers for all +// of them. +type Values map[string]string + +// Stream is one damage sitting in the write path, between the generator and +// the file. +type Stream interface { + io.Writer + + // Touched says whether at least one byte leaving here differs from the + // byte that came in. Read after the last write. + // + // A damage that changed nothing is a failed run rather than a file: the + // manifest would say expected: reject about a file every reader accepts, + // which is the tool lying exactly where its whole value is. So somebody + // has to answer this question, and the cheap answer is not the obvious + // one. + // + // Measured in internal/engine/parallel.go: the write path computes ONE + // checksum, of the bytes going to the file. Comparing "before" against + // "after" would therefore cost a SECOND sha256 of every file. A damage + // knows for free instead - zero-head has to read the bytes before it + // overwrites them, so the answer is a handful of byte compares. That is + // why this sits on the damage rather than on the engine. + Touched() bool +} + +// Descriptor is one kind of damage. +type Descriptor struct { + // ID is the name a recipe writes. A public name under untouchable rule 10. + ID string + + // Detail is the one sentence a person reads, beside the field in a window + // and under the name on the command line. + // + // It says what the damage DOES to the bytes, not what it is for. A name + // and a sentence about intent lie about half the formats already at + // twenty four: zeroing the first eight bytes has a witness in txt, md and + // log, which have no signature at all - the witness is the structural + // check refusing eight zero bytes in text, not a killed magic number. + Detail string + + // Parameters are declared the way a format declares its settings. + // + // The type is format.Property on purpose rather than a second mechanism. + // It already carries the name, the kind, a range or a closed set, a + // default and a sentence, and with it come validation, one voice of + // refusal, exit code 4 and a field drawn by the window with nothing added + // here. preset.Preset.Parameters is the same type for the same reason. + Parameters []format.Property + + // Floor is the smallest file this damage can be given, for the values + // stated. + // + // It exists because a file can be smaller than its own damage. Measured on + // 2026-09-09: txt declares a minimum of 0 B and a zero byte file really is + // produced, exit 0 - so zero-head on it would change nothing at all. That + // has to be a refusal in the PLAN rather than a failure part way through, + // because an invalid recipe writes no files at all. + // + // It is also what the witness matrix is measured at, which is the whole + // reason one number does two jobs: a witness measured at 20 kB does not + // answer for a file of 300 B, and that was measured too. + Floor func(Values) int64 + + // Open builds the transformation for one file, writing on to out. + Open func(Values, io.Writer) (Stream, error) +} + +// Spec is one entry of a damage list: which damage, and what was said to it. +type Spec struct { + ID string + Values Values +} + +var ( + mu sync.RWMutex + registry = map[string]Descriptor{} +) + +// Register adds a kind of damage. It panics on a duplicate or an incomplete +// declaration, because both are programming mistakes rather than conditions to +// handle at runtime - the same bargain the format registry makes. +func Register(d Descriptor) { + mu.Lock() + defer mu.Unlock() + + if d.ID == "" { + panic("damage: registered a descriptor with no id") + } + if _, exists := registry[d.ID]; exists { + panic(fmt.Sprintf("damage: %q is registered twice", d.ID)) + } + if d.Open == nil { + panic(fmt.Sprintf("damage: %q has nothing to apply", d.ID)) + } + if d.Floor == nil { + panic(fmt.Sprintf("damage: %q does not say how small a file it can break", d.ID)) + } + if d.Detail == "" { + panic(fmt.Sprintf("damage: %q has no sentence describing it", d.ID)) + } + for i := range d.Parameters { + format.SortChoices(d.Parameters[i].Choices) + } + registry[d.ID] = d +} + +// Get is one kind of damage by the name a recipe writes. +func Get(id string) (Descriptor, error) { + mu.RLock() + defer mu.RUnlock() + + d, ok := registry[id] + if !ok { + return Descriptor{}, &UnknownError{ID: id, Known: names()} + } + return d, nil +} + +// All is every kind of damage this build knows, in a stable order. +func All() []Descriptor { + mu.RLock() + defer mu.RUnlock() + + out := make([]Descriptor, 0, len(registry)) + for _, id := range names() { + out = append(out, registry[id]) + } + return out +} + +// Names is every kind of damage by name, in a stable order. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + return names() +} + +// names is the sorted ids. The caller holds the lock. +func names() []string { + out := make([]string, 0, len(registry)) + for id := range registry { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/damage/refusals.go b/internal/damage/refusals.go new file mode 100644 index 0000000..9f9c732 --- /dev/null +++ b/internal/damage/refusals.go @@ -0,0 +1,148 @@ +package damage + +import ( + "fmt" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The refusals this package produces, each in the four parts D6 asks for: what +// happened, why, what is allowed, and what to do instead. Kept apart from +// Error() so a report can lay them out without printing the same list twice, +// which is the shape internal/format/refusals.go settled and internal/cli +// reads through What/Why/Instead. + +// UnknownError is a damage this build does not know. +type UnknownError struct { + ID string + Known []string +} + +// What happened, without the list of names. +func (e *UnknownError) What() string { + return fmt.Sprintf("there is no damage called %q", e.ID) +} + +// Why this is refused rather than skipped. +// +// Silently ignoring it would produce an intact file whose manifest says it is +// broken, which is untouchable rule 6 read backwards: the silence is not a +// missing file but a missing hole in one. +func (e *UnknownError) Why() string { + return "a damage has to be one this build knows, and one it does not would leave the file intact while the manifest called it broken" +} + +// Instead names what there is, from the registry rather than from a list. +func (e *UnknownError) Instead() string { + if len(e.Known) == 0 { + return "remove the damage line" + } + return "use one of: " + strings.Join(e.Known, ", ") +} + +func (e *UnknownError) Error() string { + if len(e.Known) == 0 { + return e.What() + } + return e.What() + ". This build has: " + strings.Join(e.Known, ", ") +} + +// TooSmallError is a file smaller than the damage it was given. +// +// It is a refusal in the plan rather than a failure part way through, because +// the fault is in the recipe and is visible before the first byte. Measured on +// 2026-09-09: txt declares a minimum of 0 B and a zero byte file really is +// written, so this is reachable rather than theoretical. +type TooSmallError struct { + Damage string + Requested int64 + Floor int64 +} + +// AboutSetting puts this on the size box rather than at the foot of the form, +// because the size is the half a person can change without giving up the +// damage. +func (e *TooSmallError) AboutSetting() string { return "size" } + +func (e *TooSmallError) What() string { + return fmt.Sprintf("%s needs at least %s and the file is %s", + e.Damage, core.ExactBytes(e.Floor), core.ExactBytes(e.Requested)) +} + +func (e *TooSmallError) Why() string { + return "a file smaller than the damage would come out unchanged, and an unchanged file described as broken is the one thing this tool must not write" +} + +func (e *TooSmallError) Instead() string { + return fmt.Sprintf("Ask for %s or more, or take the damage off this target.", + core.ExactBytes(e.Floor)) +} + +func (e *TooSmallError) Error() string { + return e.What() + ". " + e.Instead() +} + +// NoChangeError is a damage that ran and moved nothing. +// +// Not a warning. The file would be well formed, every reader would accept it, +// and the manifest would say expected: reject about it - the tool lying in the +// exact place its value lives. It ends the run. +// +// It is per damage rather than per file on purpose. Damages compose, and two +// of them can cancel out - a second zeroing of a band already zeroed does +// nothing - so the question has to be asked after EACH step rather than once +// at the end, which would pass a list whose second entry was idle. +type NoChangeError struct { + Damage string + File string +} + +func (e *NoChangeError) What() string { + return fmt.Sprintf("%s changed nothing in %s", e.Damage, e.File) +} + +func (e *NoChangeError) Why() string { + return "the file would be accepted by every reader while the manifest called it broken, so the run stops rather than writing it" +} + +func (e *NoChangeError) Instead() string { + return "give the damage a larger file, different settings, or take it off this target" +} + +func (e *NoChangeError) Error() string { + return e.What() + ". " + e.Why() +} + +// ExpectationConflictError is a target that damages a file and expects it to +// be accepted. +// +// Only accept. reject is what damage implies and is the default, while +// sanitize and unspecified are both sensible questions to ask about a broken +// file - a system under test may well be expected to repair it, or the answer +// may be the point of the test. Owner's call on 2026-09-09: refuse the one +// that cannot be true, and leave the two that can. +type ExpectationConflictError struct { + Outcome string +} + +// AboutSetting puts this on the expectation rather than on the damage, because +// the damage is what the target is FOR and the expectation is the line that +// disagrees with it. +func (e *ExpectationConflictError) AboutSetting() string { return "expected" } + +func (e *ExpectationConflictError) What() string { + return fmt.Sprintf("this target damages the file and expects %q", e.Outcome) +} + +func (e *ExpectationConflictError) Why() string { + return "a damaged file is one a judge was measured to refuse, so expecting it to be accepted is an expectation nothing could meet" +} + +func (e *ExpectationConflictError) Instead() string { + return "leave expected out and get reject, or write sanitize if the system under test is meant to repair the file, or unspecified if that is the question" +} + +func (e *ExpectationConflictError) Error() string { + return e.What() + ". " + e.Instead() +} diff --git a/internal/damage/zerohead.go b/internal/damage/zerohead.go new file mode 100644 index 0000000..82918c6 --- /dev/null +++ b/internal/damage/zerohead.go @@ -0,0 +1,141 @@ +package damage + +import ( + "io" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// Setting names. Public names under untouchable rule 10, so they are spelled +// once. +const ( + ZeroHead = "zero-head" + + SettingBytes = "bytes" +) + +const ( + // smallestHead is the fewest bytes worth zeroing, and it is a MEASUREMENT + // rather than a round number. + // + // Measured 2026-09-09 across all twenty four formats, on a 20 kB file: + // zeroing 1 byte has 20 witnesses, 2 bytes has 20, and 4, 8, 16 and 64 + // bytes all have 24. So below four the tool would offer a damage that four + // formats produce and nothing can refuse - a file with no witness, which + // is the one thing this axis must not write. + // + // This is the same rule the column ceiling followed: the bound belongs to + // somebody else's reader, not to what our code can do. Our code can zero + // one byte perfectly well. + smallestHead = 4 + + // largestHead is where the parameter stops. + // + // Not a measured refusal - 64 bytes had 24 witnesses and nothing suggests + // a ceiling above it behaves differently. It is here because a range with + // no upper end draws a field a person can put anything in, and because a + // head larger than the file is already refused by the floor. 4096 is the + // smallest round number comfortably above every signature and header this + // tool writes. + largestHead = 4096 + + defaultHead = 8 +) + +func init() { + Register(Descriptor{ + ID: ZeroHead, + // What it does to the bytes, not what it is for. An intent-shaped + // sentence would be wrong about a third of the formats already: txt, + // md and log have no signature at all, and their witness is the + // structural check refusing zero bytes inside text. + Detail: "Overwrites the first bytes of the file with zeros, leaving its length alone. " + + "Most readers look there first, so this is the damage almost anything notices.", + Parameters: []format.Property{ + { + Name: SettingBytes, Kind: format.PropertyInt, + Min: smallestHead, Max: largestHead, Unit: "bytes", + Default: strconv.Itoa(defaultHead), + Detail: "How many bytes at the start are zeroed. " + + "Below four, some formats come out with damage no reader complains about.", + }, + }, + Floor: func(v Values) int64 { return int64(headBytes(v)) }, + Open: func(v Values, out io.Writer) (Stream, error) { + return &zeroHead{out: out, left: headBytes(v)}, nil + }, + }) +} + +// headBytes is how many bytes this run zeroes. +// +// The value has been past the declaration by the time it arrives, so a bad one +// cannot reach here through either surface. It falls back rather than failing +// for the same reason textenc.Parse keeps its branches: this is callable +// directly, a guard is such a caller, and code that trusts its input is one +// registry change away from writing a file nobody ordered. +func headBytes(v Values) int { + raw, ok := v[SettingBytes] + if !ok || raw == "" { + return defaultHead + } + n, err := strconv.Atoi(raw) + if err != nil || n < smallestHead || n > largestHead { + return defaultHead + } + return n +} + +// zeroHead zeroes the opening bytes of a stream as it goes. +// +// Streaming rather than buffering, which keeps the regression line "a +// generator does not hold the whole file in memory" untouched: it holds one +// scratch buffer the size of the head, never the file. +type zeroHead struct { + out io.Writer + // left is how many bytes at the front are still to be zeroed. + left int + // touched records whether any byte we zeroed was not already zero. See + // Stream.Touched for why the damage answers this rather than the engine. + touched bool + // buf is where the altered bytes go. The slice a caller hands to Write + // belongs to the caller, so writing zeros into it in place would be + // changing somebody else's memory - the same rule the archive encrypter + // had to learn. + buf []byte +} + +func (z *zeroHead) Write(p []byte) (int, error) { + if z.left == 0 { + return z.out.Write(p) + } + + n := z.left + if n > len(p) { + n = len(p) + } + for _, b := range p[:n] { + if b != 0 { + z.touched = true + break + } + } + + z.buf = append(z.buf[:0], p...) + for i := 0; i < n; i++ { + z.buf[i] = 0 + } + z.left -= n + + written, err := z.out.Write(z.buf) + if err != nil { + return written, err + } + // The caller is told about ITS bytes, not about ours. They are the same + // count here because this damage preserves length, and saying len(p) + // rather than written is what keeps that true for a caller that checks. + return len(p), nil +} + +func (z *zeroHead) Touched() bool { return z.touched } diff --git a/internal/engine/damage.go b/internal/engine/damage.go new file mode 100644 index 0000000..6b3ea96 --- /dev/null +++ b/internal/engine/damage.go @@ -0,0 +1,63 @@ +// Damage as the engine sees it: what a target may be refused for before +// anything is written, and what the manifest records afterwards. +// +// In its own file rather than in engine.go, and that is a measurement: engine.go +// stood at 431 lines of code against a ceiling of 433, so two more functions +// there would have pushed a file over a gate that exists to stop exactly this. +// Cutting a file does not make a type smaller, but these two were never part of +// what engine.go is about - they are one subject, which is the better reason. +package engine + +import ( + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" +) + +// checkDamageFloor refuses a file smaller than the damage it was given. +// +// Here rather than at the moment of writing, and that is the point: a file +// smaller than its damage comes out unchanged, and an unchanged file the +// manifest calls broken is the one thing this axis must not produce. The fault +// is in the recipe and is visible before the first byte, so it is a refusal +// during planning - which is what makes "an invalid recipe writes no files" +// true here as everywhere else. +// +// Reachable rather than theoretical: txt declares a minimum of 0 B and a zero +// byte file really is written, measured 2026-09-09. +// +// A container whose size comes from its contents is left alone. Its entries in +// Sizes carry only the count and their value is not read, so comparing them +// against anything would be comparing against a number nobody set. The damage +// still applies to the finished archive, and the check that it moved a byte +// still runs - what is missing is only the early refusal. +func checkDamageFloor(t *Target) error { + if len(t.Damage) == 0 || t.SizeFromContents { + return nil + } + floor, owner, err := t.Damage.Floor() + if err != nil { + return err + } + for _, size := range t.Sizes { + if size < floor { + return &damage.TooSmallError{Damage: owner, Requested: size, Floor: floor} + } + } + return nil +} + +// damageFor records what was broken about this file, with the settings +// resolved rather than as written. +// +// Nil for a file nothing damaged, so the key is absent rather than empty and +// every manifest written before this existed is unchanged. +func damageFor(f PlannedFile) []manifest.Damage { + if !f.Damaged() { + return nil + } + out := make([]manifest.Damage, 0, len(f.Target.Damage)) + for _, s := range f.Target.Damage { + out = append(out, manifest.Damage{Type: s.ID, Settings: s.Resolved()}) + } + return out +} diff --git a/internal/engine/drawsizes.go b/internal/engine/drawsizes.go index b366f85..9632765 100644 --- a/internal/engine/drawsizes.go +++ b/internal/engine/drawsizes.go @@ -119,31 +119,31 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error { // UTF-16, the band above a PNG's encoded picture - is a different thing: // nobody can be expected to enumerate those, and snapping inside the range // is the answer. - floor, err := firstWritable(desc, first, 0, t.SizeMax) + floor, err := firstWritable(desc, first, 0, t.Range.Max) if err != nil { // The floor is above the whole range, so nothing in it is writable. return err } - if t.SizeMin < floor { + if t.Range.Min < floor { // Asked again at the low end so the format words its own refusal, with // the number the person actually wrote. - first.Bytes = t.SizeMin + first.Bytes = t.Range.Min if _, err := planWithoutCrashing(desc, first); err != nil { return err } } - span := uint64(t.SizeMax - t.SizeMin) + span := uint64(t.Range.Max - t.Range.Min) t.SizeMoved = make([]bool, len(t.Sizes)) for i := range t.Sizes { - want := t.SizeMin + want := t.Range.Min if span != 0 { // Per index, never from a running stream. Raising a count then // leaves the sizes of the earlier files alone, which is rule 2 and // the reason core.SizeSeed takes an index at all. r := core.NewRand(core.SizeSeed(targetSeed, i)) - want = t.SizeMin + int64(r.Uint64N(span+1)) + want = t.Range.Min + int64(r.Uint64N(span+1)) } req := format.Request{ @@ -153,13 +153,13 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error { Properties: t.Properties, } - got, err := firstWritable(desc, req, want, t.SizeMax) - if err != nil && want > t.SizeMin { + got, err := firstWritable(desc, req, want, t.Range.Max) + if err != nil && want > t.Range.Min { // Nothing writable from the draw upwards. The bottom of the range // can still hold something - a draw landing on the last odd number // of a range has nowhere above it and plenty below - so the range is // only empty once THAT fails too. - got, err = firstWritable(desc, req, t.SizeMin, t.SizeMax) + got, err = firstWritable(desc, req, t.Range.Min, t.Range.Max) } if err != nil { return err diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9ac0b24..9604571 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/manifest" "github.com/donislawdev/TestingFilesGenerator/internal/version" @@ -40,17 +41,20 @@ type Target struct { // from Contains. The entries in Sizes then only carry the count, and their // value is not read. SizeFromContents bool - // SizeIsRange says the sizes are drawn from SizeMin to SizeMax rather than - // stated, and Sizes arrives carrying only the count. + // Range says the sizes are drawn rather than stated, and Sizes then arrives + // carrying only the count. + // + // One field rather than three, because the type stood at the crowding band + // and the answer to that is to move state out rather than raise a number. + // They were one statement to begin with: a minimum without the flag beside + // it says nothing, and neither does a maximum. // // The draw happens here rather than in the recipe package because this is // the first place that knows the seed the run will actually use - the // --seed flag overrides the recipe, and a size drawn before that would // belong to a different run than the manifest describes. It still happens // during planning, so AR10 holds and a dry run reports exact numbers. - SizeIsRange bool - SizeMin int64 - SizeMax int64 + Range SizeRange // SizeMoved marks the files whose drawn size was not one the format can // write, so the nearest writable one was used instead. Empty for a target // that is not a range. @@ -77,6 +81,21 @@ type Target struct { // manifest, so a test can assert about a whole class at once. Group string Properties map[string]string + // Damage is what to break about these files, in the order to break it. + // + // Empty for every target that does not ask, and that emptiness is what + // keeps D11 whole: a run with no damage goes down the same write path it + // always did, byte for byte. + Damage damage.Chain +} + +// SizeRange is a size drawn per file rather than stated. +// +// Used is what says a range was asked for at all. Min and Max mean nothing +// without it, which is why they travel together. +type SizeRange struct { + Used bool + Min, Max int64 } // Uniform is n files of the same size, which is what most targets ask for. @@ -192,6 +211,11 @@ type PlannedFile struct { Plan format.Plan } +// Damaged says whether this file is broken on purpose. +func (f PlannedFile) Damaged() bool { + return f.Target != nil && len(f.Target.Damage) > 0 +} + // settleTarget checks everything that has to be true about one target before // any of its files are planned, and settles the sizes of a range. // @@ -245,11 +269,18 @@ func settleTarget(t *Target, opt Options, seen map[string]bool) (format.Descript // every generator already refuses and a guard walks sizes below the minimum // for every registered format. It was removed rather than kept as defence // nobody can verify. - if t.SizeIsRange { + if t.Range.Used { if err := drawSizes(t, desc, core.TargetSeed(opt.Seed, t.ID)); err != nil { return format.Descriptor{}, err } } + + // After the draw, because a range arrives here carrying only a count and a + // damage floor has to be judged against the sizes that will really be + // written. + if err := checkDamageFloor(t); err != nil { + return format.Descriptor{}, err + } return desc, nil } @@ -627,6 +658,7 @@ func entryFor(f PlannedFile, sha string, materialized bool, failure error) manif Determinism: string(f.Plan.Determinism), Properties: f.Plan.Properties, LabelEmbedded: label, + Damage: damageFor(f), Notes: notes, Expected: expectationFor(f), Group: f.Target.Group, @@ -648,6 +680,24 @@ func entryFor(f PlannedFile, sha string, materialized bool, failure error) manif func expectationFor(f PlannedFile) manifest.Expected { switch f.Target.Expected { case "": + // A damaged file is one a judge was measured to refuse, so reject is + // what it means rather than a guess - which is why this is allowed to + // state it with certainty where an ordinary file gets unspecified. + // Untouchable rule 5 forbids inventing an expectation, and this one is + // not invented: the witness rule says a damage with nothing able to + // refuse its result is a damage this tool does not offer. + // + // Only where nothing was declared. A recipe that states an expectation + // reaches the manifest unchanged, and the one statement that cannot be + // true beside damage - accept - is refused while reading the recipe + // rather than quietly replaced here. + if f.Damaged() { + return manifest.Expected{ + Outcome: manifest.OutcomeReject, + Reason: "content_malformed", + Confidence: "certain", + } + } return manifest.Expected{ Outcome: manifest.OutcomeUnspecified, Detail: "No expectation was declared for this file.", diff --git a/internal/engine/errors.go b/internal/engine/errors.go index 7c57231..a53da95 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -169,7 +169,7 @@ func atTarget(position int, t *Target, err error) error { // is in hand. Without it a recipe written with "size-range" is refused at // "targets[1].size", which is a box that is not on the screen and not in // the file. Measured on 2026-09-06. - if setting == core.SettingSize && t != nil && t.SizeIsRange { + if setting == core.SettingSize && t != nil && t.Range.Used { setting = core.SettingSizeRange } return &addressedError{err: err, at: core.TargetAddress(position, setting)} diff --git a/internal/engine/parallel.go b/internal/engine/parallel.go index edbb867..2adf17a 100644 --- a/internal/engine/parallel.go +++ b/internal/engine/parallel.go @@ -17,6 +17,7 @@ import ( "sync/atomic" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" ) // This file is the only place in internal/engine that runs anything beside @@ -339,10 +340,42 @@ func writeOne(ctx context.Context, f PlannedFile, outDir string, p *fileProgress counter.report = p.advance } - writeErr := writeWithoutCrashing(ctx, f, counter) + // Damage sits between the generator and the counter, and the order is the + // design rather than a convenience. + // + // generator -> DAMAGE -> counter -> MultiWriter( buffered -> file, h ) + // + // Three things fall out of it and none had to be built. The checksum + // describes the bytes that reached the disk, which is what stops the + // manifest describing a file other than the one beside it. The size check + // below counts the FINAL bytes, so a damage that changed the length + // against the plan is stopped by a guard that already existed. And + // progress counts what will really be there. + sink := io.Writer(counter) + var streams []damage.Stream + if f.Damaged() { + damaged, opened, err := f.Target.Damage.Open(counter) + if err != nil { + _ = fh.Close() + _ = os.Remove(tmp) + return "", err + } + sink, streams = damaged, opened + } + + writeErr := writeWithoutCrashing(ctx, f, sink) if writeErr == nil { writeErr = buffered.Flush() } + if writeErr == nil { + // Asked per damage rather than once at the end, because two damages + // can cancel each other out and a chain whose second member was idle + // would otherwise pass. A file nothing changed is a file every reader + // accepts while the manifest calls it broken. + if idle := f.Target.Damage.Idle(streams); idle != "" { + writeErr = &damage.NoChangeError{Damage: idle, File: f.Name} + } + } closeErr := fh.Close() if writeErr != nil { diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go index 7fa5b3a..cbbda54 100644 --- a/internal/guard/concurrency_test.go +++ b/internal/guard/concurrency_test.go @@ -28,6 +28,12 @@ var mayBeConcurrent = map[string]string{ // The registry is read by every generator and written once at startup, so // it carries the one lock in the tree. "internal/format/registry.go": "the format registry is written at init and read by everything after", + // The same shape one axis over, and the same reason: written once when the + // package starts and read by planning, by the recipe reader, by both + // surfaces and by the guards. It holds the second lock in the tree because + // it is the second registry, not because anything here runs beside + // anything else. + "internal/damage/damage.go": "the damage registry is written at init and read by everything after", // Signals arrive on a channel by definition, and the handler has to run // beside the work it interrupts. "cmd/tfg/main.go": "the interrupt handler has to run beside the work it stops", diff --git a/internal/guard/damagebytes_test.go b/internal/guard/damagebytes_test.go new file mode 100644 index 0000000..7ef2275 --- /dev/null +++ b/internal/guard/damagebytes_test.go @@ -0,0 +1,184 @@ +package guard + +import ( + "bytes" + "strconv" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" +) + +// The byte layer of damage, asked directly. +// +// These are the narrowest tests in the feature and they are here rather than +// left to the end to end ones on purpose: a damage that writes the wrong bytes +// still produces a file an oracle refuses, so the guard that asks "is it +// refused" would stay green while the damage did something other than what it +// says. What it DOES has to be pinned separately from what it ACHIEVES. + +// zero-head zeroes exactly the bytes it says and leaves the rest alone. +// +// The stream is written in pieces of three, because that is the shape the real +// caller has - a generator writes in whatever chunks it likes, and a damage +// that only works when the head arrives in one write is a damage that works in +// a test and not in the product. +func TestZeroHeadZeroesTheBytesItSaysAndNoOthers(t *testing.T) { + for _, head := range []int{4, 8, 16} { + t.Run(strconv.Itoa(head), func(t *testing.T) { + const size = 64 + source := make([]byte, size) + for i := range source { + source[i] = byte(i + 1) + } + + var out bytes.Buffer + chain := damage.Chain{{ + ID: damage.ZeroHead, + Values: damage.Values{damage.SettingBytes: strconv.Itoa(head)}, + }} + w, streams, err := chain.Open(&out) + if err != nil { + t.Fatalf("opening the chain: %v", err) + } + + for i := 0; i < len(source); i += 3 { + end := min(i+3, len(source)) + if n, err := w.Write(source[i:end]); err != nil || n != end-i { + t.Fatalf("writing bytes %d to %d gave %d, %v", i, end, n, err) + } + } + + got := out.Bytes() + if len(got) != size { + t.Fatalf("zero-head changed the length: %d B in, %d B out", size, len(got)) + } + for i := 0; i < head; i++ { + if got[i] != 0 { + t.Errorf("byte %d is %#x and should have been zeroed", i, got[i]) + } + } + for i := head; i < size; i++ { + if got[i] != source[i] { + t.Errorf("byte %d is %#x and the source had %#x - past the head nothing may move", + i, got[i], source[i]) + } + } + if !streams[0].Touched() { + t.Error("every one of those bytes was non zero and the damage says it touched nothing") + } + }) + } +} + +// A damage that changed nothing says so. +// +// This is the half the whole feature stands on. A file whose head is already +// zero comes out identical to a good one, every reader accepts it, and the +// manifest would call it broken - the tool lying in the one place its value +// lives. The engine refuses the run on this answer, so the answer has to be +// right in both directions, and the case above is the other direction. +func TestADamageThatMovedNothingSaysSo(t *testing.T) { + source := make([]byte, 32) // all zero already + + var out bytes.Buffer + chain := damage.Chain{{ID: damage.ZeroHead}} + w, streams, err := chain.Open(&out) + if err != nil { + t.Fatalf("opening the chain: %v", err) + } + if _, err := w.Write(source); err != nil { + t.Fatalf("writing: %v", err) + } + + if streams[0].Touched() { + t.Error("the head was already zero and the damage claims it changed something") + } + if idle := chain.Idle(streams); idle != damage.ZeroHead { + t.Errorf("the chain names %q as the idle damage rather than %q", idle, damage.ZeroHead) + } + if !bytes.Equal(out.Bytes(), source) { + t.Error("nothing was supposed to change and the bytes moved") + } +} + +// The floor is the smallest file the damage can be given, and it follows the +// parameter rather than being a constant. +// +// It has two jobs and that is why one number does both: it is what the plan +// refuses below, and it is the size the witness matrix is measured at. A floor +// that ignored the parameter would answer for one of those and not the other. +func TestTheFloorFollowsThePairOfSettings(t *testing.T) { + for _, c := range []struct { + head string + want int64 + }{ + {"", 8}, // the declared default + {"4", 4}, + {"64", 64}, + } { + chain := damage.Chain{{ + ID: damage.ZeroHead, + Values: damage.Values{damage.SettingBytes: c.head}, + }} + got, owner, err := chain.Floor() + if err != nil { + t.Fatalf("asking the floor for head %q: %v", c.head, err) + } + if got != c.want { + t.Errorf("head %q has a floor of %d B and should be %d B", c.head, got, c.want) + } + if owner != damage.ZeroHead { + t.Errorf("the floor is credited to %q rather than to the damage that set it", owner) + } + } +} + +// A chain applies its damages in the order it lists them. +// +// Order is part of the contract because two damages can produce different +// bytes in different orders, and D11 promises those bytes do not move. Asked +// with two zero-heads of different lengths, where the wrong order is visible +// in the result: the longer one first leaves sixteen zeros, the shorter one +// first leaves the same sixteen only if the second really ran after it. +func TestAChainAppliesItsDamagesInTheOrderItListsThem(t *testing.T) { + source := make([]byte, 32) + for i := range source { + source[i] = 0xFF + } + + var out bytes.Buffer + chain := damage.Chain{ + {ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "4"}}, + {ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "16"}}, + } + w, streams, err := chain.Open(&out) + if err != nil { + t.Fatalf("opening the chain: %v", err) + } + if _, err := w.Write(source); err != nil { + t.Fatalf("writing: %v", err) + } + + got := out.Bytes() + for i := 0; i < 16; i++ { + if got[i] != 0 { + t.Fatalf("byte %d is %#x - the second damage did not run after the first", i, got[i]) + } + } + if got[16] != 0xFF { + t.Errorf("byte 16 is %#x and nothing should have reached it", got[16]) + } + + // The second one meets a head whose first four bytes are already zero and + // twelve that are not, so it still moved something. The first met all + // ones. Both have to say they were busy, because the idle check runs per + // damage rather than once at the end. + for i, s := range streams { + if !s.Touched() { + t.Errorf("damage %d says it changed nothing and it had bytes to change", i) + } + } + if idle := chain.Idle(streams); idle != "" { + t.Errorf("the chain names %q as idle and neither damage was", idle) + } +} diff --git a/internal/guard/damagerefused_test.go b/internal/guard/damagerefused_test.go new file mode 100644 index 0000000..02382b4 --- /dev/null +++ b/internal/guard/damagerefused_test.go @@ -0,0 +1,370 @@ +package guard + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" + "github.com/donislawdev/TestingFilesGenerator/internal/oracle" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// A damaged file has to be REFUSED, which is the reverse of everything else in +// this package. +// +// Every other oracle guard here asks "does a judge accept what we wrote". This +// one asks the opposite, and it is the whole reason the damage axis is worth +// having: a broken file no reader complains about is a file nobody can write a +// test against, so it is a file this tool has no business producing. +// +// The formats are the text ones plus png, because those are the ones whose +// judges run without an external tool being installed. That is a limit of what +// can be asked on any machine rather than of the damage: the full sweep across +// all twenty four lives in tools/probes/corruptwitness, which is where the +// witness matrix is measured. +func TestADamagedFileIsRefusedByAJudge(t *testing.T) { + dir := t.TempDir() + + for _, id := range []string{"txt", "md", "json", "xml", "csv", "html", "png"} { + t.Run(id, func(t *testing.T) { + good, broken := writeGoodAndBroken(t, dir, id) + + // The control first. Without it this test passes for a build whose + // judge refuses everything, which would be the loudest possible + // way of proving nothing. + if res := oracle.Strict(id, good); res.Available && res.Err != nil { + t.Fatalf("the undamaged file was refused, so this row says nothing about damage: %v\n%s", + res.Err, res.Output) + } + + res := oracle.Strict(id, broken) + if !res.Available { + t.Skipf("no structural check for %s on this machine", id) + } + if res.Err == nil { + t.Errorf("the damaged file was ACCEPTED. A broken file every reader takes is one "+ + "nobody can write a test against, and the manifest calls it expected: reject.\n%s", + res.Output) + } + }) + } +} + +// The manifest of a damaged run says what was broken and expects a rejection. +// +// Read from the file on disk rather than from the plan, because the whole +// point of the record is what a consumer finds in it. +func TestTheManifestOfADamagedRunSaysSoAndExpectsRejection(t *testing.T) { + dir := t.TempDir() + man := runDamaged(t, dir, "png", damage.Chain{{ + ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "16"}, + }}) + + if len(man.Files) != 1 { + t.Fatalf("expected one file in the manifest, got %d", len(man.Files)) + } + f := man.Files[0] + + if len(f.Damage) != 1 { + t.Fatalf("the manifest records %d damage(s) and one was applied", len(f.Damage)) + } + if f.Damage[0].Type != damage.ZeroHead { + t.Errorf("the manifest names %q rather than the damage that was applied", f.Damage[0].Type) + } + // Resolved rather than as written, so a consumer does not have to know + // what the default was in the build that wrote it. + if got := f.Damage[0].Settings[damage.SettingBytes]; got != "16" { + t.Errorf("the manifest records bytes=%q rather than what was asked for", got) + } + if f.Expected.Outcome != manifest.OutcomeReject { + t.Errorf("a damaged file is expected to be %q rather than %q", + manifest.OutcomeReject, f.Expected.Outcome) + } + // Stated with certainty rather than as a guess, and allowed to be: the + // witness rule says a damage nothing can refuse is not offered, so this is + // measured rather than invented. Untouchable rule 5 is about the second. + if f.Expected.Confidence != "certain" { + t.Errorf("the expectation is %q confident and it is measured, not guessed", f.Expected.Confidence) + } +} + +// An undamaged run is untouched by any of this. +// +// D11 in the narrowest place it can be asked: the bytes of a file nobody +// damaged, and the manifest entry beside it, both have to be what they were +// before the axis existed. The damage key is ABSENT rather than empty, so a +// manifest written by an older build is still the same document. +func TestARunWithNoDamageIsUnchanged(t *testing.T) { + dir := t.TempDir() + man := runDamaged(t, dir, "png", nil) + + f := man.Files[0] + if f.Damage != nil { + t.Errorf("a run with no damage recorded %v", f.Damage) + } + if f.Expected.Outcome != manifest.OutcomeUnspecified { + t.Errorf("a file nobody damaged expects %q rather than %q", + manifest.OutcomeUnspecified, f.Expected.Outcome) + } + + body, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil { + t.Fatal(err) + } + // The signature, which is the first thing zero-head would have taken. + if len(body) < 8 || body[0] != 0x89 || string(body[1:4]) != "PNG" { + t.Errorf("the file does not open with the PNG signature: % x", body[:min(8, len(body))]) + } +} + +// A damage that would change nothing stops the file rather than writing it. +// +// Reachable rather than theoretical, and that is measured: ico, avif and jxl +// all begin with zero bytes, so zeroing one or two of them moves nothing. Here +// it is asked of a file made of zeros, because the guard has to press the +// answer rather than depend on which formats happen to start that way. +func TestADamageThatWouldChangeNothingStopsTheFile(t *testing.T) { + source := make([]byte, 64) + chain := damage.Chain{{ID: damage.ZeroHead}} + + var sink discard + w, streams, err := chain.Open(&sink) + if err != nil { + t.Fatalf("opening the chain: %v", err) + } + if _, err := w.Write(source); err != nil { + t.Fatalf("writing: %v", err) + } + if idle := chain.Idle(streams); idle == "" { + t.Fatal("every byte was already zero and the chain reports nothing idle") + } +} + +// discard swallows bytes. The chain has to write somewhere and what it wrote +// is not the question here - whether it moved anything is. +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +// writeGoodAndBroken produces one file of this format twice, once whole and +// once damaged, and returns both paths. +func writeGoodAndBroken(t *testing.T, dir, id string) (good, broken string) { + t.Helper() + + d, err := format.Get(id) + if err != nil { + t.Fatal(err) + } + size := d.SmallestAccepted(format.Request{}) + 4096 + + goodDir := filepath.Join(dir, id+"-good") + brokenDir := filepath.Join(dir, id+"-broken") + goodMan := runOne(t, goodDir, id, size, nil) + brokenMan := runOne(t, brokenDir, id, size, damage.Chain{{ID: damage.ZeroHead}}) + + return filepath.Join(goodDir, goodMan.Files[0].Name), + filepath.Join(brokenDir, brokenMan.Files[0].Name) +} + +// runDamaged is one png of a comfortable size, with the chain given. +func runDamaged(t *testing.T, dir, id string, chain damage.Chain) *manifest.Manifest { + t.Helper() + return runOne(t, dir, id, 20<<10, chain) +} + +// runOne writes one file through the whole engine and hands back the manifest +// it wrote, read from disk. +func runOne(t *testing.T, dir, id string, size int64, chain damage.Chain) *manifest.Manifest { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + target := engine.Target{ + ID: "d", + Format: id, + Sizes: engine.Uniform(1, size), + Damage: chain, + } + opt := engine.Options{OutDir: dir, Seed: 5, ManifestName: engine.DefaultManifestName} + + planned, err := engine.Plan([]engine.Target{target}, opt) + if err != nil { + t.Fatalf("planning %s: %v", id, err) + } + res, err := engine.Run(t.Context(), planned, opt) + if err != nil { + t.Fatalf("running %s: %v", id, err) + } + for _, f := range res.Manifest.Files { + if f.Failed { + t.Fatalf("%s: the run reported a failure: %s", id, f.Error) + } + } + return res.Manifest +} + +// A file smaller than its damage is refused BEFORE anything is written. +// +// In the plan rather than at the moment of writing, because the fault is in +// the recipe and is visible before the first byte - which is what makes "an +// invalid recipe writes no files" true here as everywhere else. +// +// Reachable rather than theoretical: txt declares a minimum of 0 B and a zero +// byte file really is produced, so a person can ask for one and damage it. +func TestAFileSmallerThanItsDamageIsRefusedBeforeAnythingIsWritten(t *testing.T) { + dir := t.TempDir() + target := engine.Target{ + ID: "tiny", + Format: "txt", + Sizes: engine.Uniform(1, 4), + Damage: damage.Chain{{ID: damage.ZeroHead}}, // eight bytes by default + } + opt := engine.Options{OutDir: dir, ManifestName: engine.DefaultManifestName} + + _, err := engine.Plan([]engine.Target{target}, opt) + if err == nil { + t.Fatal("a 4 B file was planned with a damage that needs 8 B") + } + + var small *damage.TooSmallError + if !errors.As(err, &small) { + t.Fatalf("refused with %T, which nothing can take apart: %v", err, err) + } + if small.Floor != 8 || small.Requested != 4 { + t.Errorf("the refusal says floor %d and requested %d", small.Floor, small.Requested) + } + // The four parts D6 asks for, and the one that matters most: it names a + // size that would work rather than only saying no. + if !strings.Contains(small.Instead(), "8 B") { + t.Errorf("the refusal does not name a size that would work: %q", small.Instead()) + } + + // Nothing on disk. The whole reason this is a planning refusal. + left, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Errorf("the run was refused and left %d file(s) behind", len(left)) + } +} + +// A size the damage CAN take is planned, which is the control. +// +// Without it the test above passes for a build that refuses every damaged +// target, and that would be a floor that has become a wall. +func TestASizeTheDamageCanTakeIsPlanned(t *testing.T) { + target := engine.Target{ + ID: "fine", + Format: "txt", + Sizes: engine.Uniform(1, 8), + Damage: damage.Chain{{ID: damage.ZeroHead}}, + } + opt := engine.Options{OutDir: t.TempDir(), ManifestName: engine.DefaultManifestName} + + if _, err := engine.Plan([]engine.Target{target}, opt); err != nil { + t.Fatalf("8 B is exactly what the damage needs and it was refused: %v", err) + } +} + +// Damage beside an expectation of accept is refused, and the other three +// outcomes are not. +// +// Only accept, and the narrowness is the decision. reject is what damage +// implies, and sanitize and unspecified are both sensible questions about a +// broken file - a system under test may be expected to repair it, or that may +// be the point of the test. Refusing more than the one that cannot be true +// would take away cases somebody has a right to build. +// +// The half that matters is the second loop. Without it this passes for a build +// that refuses damage beside ANY expectation, which is the wall version of the +// same rule and would be invisible from the first loop alone. +func TestDamageBesideAnExpectationOfAcceptIsRefused(t *testing.T) { + body := func(outcome string) []byte { + return []byte(`version: 1 +targets: + - id: broken + format: png + size: 20kb + damage: [zero-head] + expected: ` + outcome + ` +`) + } + + if _, err := recipe.Parse(body("accept"), "r.yaml"); err == nil { + t.Error("a target that breaks the file and expects it to be accepted was accepted") + } else if !strings.Contains(err.Error(), "accept") { + t.Errorf("the refusal does not say which expectation it is about: %v", err) + } + + for _, outcome := range []string{"reject", "sanitize", "unspecified"} { + if _, err := recipe.Parse(body(outcome), "r.yaml"); err != nil { + t.Errorf("expected %s beside damage is a legitimate question and was refused: %v", + outcome, err) + } + } +} + +// A damage the build does not know is refused while the recipe is read, and +// the refusal names what there is. +// +// While the recipe is read rather than when the file is written, because an +// invalid recipe produces no files at all - and the names come from the +// registry, so a second damage appears in that sentence on the day it is +// registered. +func TestADamageNobodyRegisteredIsRefusedWithTheNamesThereAre(t *testing.T) { + src := []byte(`version: 1 +targets: + - id: broken + format: png + size: 20kb + damage: [shred-it] +`) + + _, err := recipe.Parse(src, "r.yaml") + if err == nil { + t.Fatal("a damage nobody registered was accepted") + } + for _, want := range []string{"shred-it", damage.ZeroHead} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } +} + +// A damage setting outside its declaration is refused in the registry's own +// words. +// +// The wording is not written in the damage package and must not be: bytes is +// declared as a number with a range, and format.Property.Allows builds the +// sentence - so a damage parameter and a format setting refuse alike, and +// neither copies the other. Four is the floor and it is measured: below it, +// four of the twenty four formats come out with damage no reader complains +// about. +func TestADamageSettingOutsideItsDeclarationIsRefused(t *testing.T) { + src := []byte(`version: 1 +targets: + - id: broken + format: png + size: 20kb + damage: + - type: zero-head + bytes: 2 +`) + + _, err := recipe.Parse(src, "r.yaml") + if err == nil { + t.Fatal("zeroing two bytes was accepted and four formats have no witness for it") + } + if !strings.Contains(err.Error(), "bytes") { + t.Errorf("the refusal does not name the setting: %v", err) + } +} diff --git a/internal/guard/damagewindow_test.go b/internal/guard/damagewindow_test.go new file mode 100644 index 0000000..8cd9653 --- /dev/null +++ b/internal/guard/damagewindow_test.go @@ -0,0 +1,165 @@ +package guard + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" +) + +// The window offers every damage the registry holds. +// +// Asked of the registry rather than of a list written here, so a second damage +// appears in this menu on the day it is registered - the same bargain the +// format menu makes, and the reason D1 is answered by declaring rather than by +// remembering. +func TestTheWindowOffersEveryDamageThereIs(t *testing.T) { + _, content := screen(t) + + control := controlUnder(content, text.FieldDamage()) + picker, ok := control.(*parts.Chooser) + if !ok { + t.Fatalf("the damage field is %T rather than a list to choose from", control) + } + + offered := append([]string{}, picker.Options...) + want := append([]string{text.DamageNone()}, damage.Names()...) + sort.Strings(offered) + sort.Strings(want) + if strings.Join(offered, ",") != strings.Join(want, ",") { + t.Errorf("the window offers %v and the registry has %v", offered, want) + } + + // It opens on none, because damage is the exception rather than the + // ordinary run - and because a menu cannot be empty, so "not damaged" has + // to be one of its values rather than the absence of a choice. + if picker.Selected != text.DamageNone() { + t.Errorf("the window opens with %q chosen rather than %q", picker.Selected, text.DamageNone()) + } +} + +// Choosing a damage draws the fields it declares, and choosing none takes them +// away again. +// +// The second half is the one worth having. A field left behind under a menu +// set back to none is a value that reaches the engine from a control nobody +// can see, which is how a preset parameter once travelled without a widget. +func TestTheWindowDrawsAFieldForEveryDamageParameter(t *testing.T) { + _, content := screen(t) + picker := controlUnder(content, text.FieldDamage()).(*parts.Chooser) + + checked := 0 + for _, d := range damage.All() { + picker.SetSelected(d.ID) + for _, p := range d.Parameters { + control := controlUnder(content, text.SettingLabel(p.Name)) + if control == nil { + t.Errorf("%s declares %q and the window draws no field for it", d.ID, p.Name) + continue + } + if bad := wrongKindOfControl(p, control); bad != "" { + t.Errorf("%s.%s is %s", d.ID, p.Name, bad) + } + checked++ + } + } + if checked == 0 { + t.Fatal("no damage declares a parameter, so this proved nothing") + } + + picker.SetSelected(text.DamageNone()) + for _, d := range damage.All() { + for _, p := range d.Parameters { + if controlUnder(content, text.SettingLabel(p.Name)) != nil { + t.Errorf("%q is still on the screen with no damage chosen", p.Name) + } + } + } + t.Logf("%d damage parameter(s) drawn from the registry", checked) +} + +// A run started from the window is damaged, and the bytes say so. +// +// Pressed rather than settled, because settle is the window's own and a guard +// that called it would prove the screen agrees with itself. Read from the file +// rather than from the screen: a window that draws the menu and drops the +// choice on the way to the engine looks exactly like one that works, which is +// the defect that once let the window produce PDFs while the command line +// produced anything. +func TestARunFromTheWindowIsReallyDamaged(t *testing.T) { + dir := t.TempDir() + + host := newFakeHost(t) + gen := window.NewGenerate(host) + content := gen.Object() + t.Cleanup(func() { join(host) }) + + fields := gen.Fields() + chooserIn(t, fields, "format").SetSelected("png") + setBox(t, fields, "size", "20kb") + + chooser, ok := controlUnder(content, text.FieldDamage()).(*parts.Chooser) + if !ok { + t.Fatalf("the damage field is not a list to choose from") + } + chooser.SetSelected(damage.ZeroHead) + setBox(t, fields, damage.SettingBytes, "16") + + entryUnder(t, content, text.FieldOutputDir()).SetText(dir) + press(t, content, text.ButtonGenerate()) + join(host) + + made, err := filepath.Glob(filepath.Join(dir, "*.png")) + if err != nil || len(made) != 1 { + t.Fatalf("the window wrote %v (err %v) and this guard needs exactly one file", made, err) + } + body, err := os.ReadFile(made[0]) + if err != nil { + t.Fatal(err) + } + // Sixteen, because that is what was typed into the parameter. Eight would + // pass for a window that drew the field and sent the default. + for i := 0; i < 16; i++ { + if body[i] != 0 { + t.Fatalf("byte %d is %#x, so the choice or its setting never reached the engine", i, body[i]) + } + } + if int64(len(body)) != 20<<10 { + t.Errorf("the file is %d B and the size asked for was %d B", len(body), 20<<10) + } + + f := damageManifest(t, filepath.Join(dir, engine.DefaultManifestName)).Files[0] + if len(f.Damage) != 1 || f.Damage[0].Type != damage.ZeroHead { + t.Fatalf("the manifest records %v rather than the damage that was chosen", f.Damage) + } + if got := f.Damage[0].Settings[damage.SettingBytes]; got != "16" { + t.Errorf("the manifest records bytes=%q rather than what was typed", got) + } +} + +// damageManifest reads a manifest as the manifest package spells it. +// +// Its own reader rather than the manifestShape the recipe guards share: that +// one is a narrow view written for what those guards ask, and adding a field +// to it for one caller would make every other guard carry it. +func damageManifest(t *testing.T, path string) manifest.Manifest { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading the manifest: %v", err) + } + var m manifest.Manifest + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("parsing the manifest: %v", err) + } + return m +} diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 58cbd83..2232cf5 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -69,6 +69,18 @@ var layer = map[string]int{ "internal/preset": 2, "internal/manifest": 2, + // What a file can be broken with. Layer 2 rather than 1, beside the other + // pieces of the contract, because it imports internal/format cleanly + // downwards for format.Property - exactly as internal/preset does, and for + // the same reason: a damage parameter IS a format property, so it gets the + // validation, the one voice of refusal and the window's field for free. + // + // Layer 1 beside the formats was the other candidate and was turned down: + // damage transforms bytes, which makes it tempting, but it is not a + // generator and down there it would need a sideways edge to + // internal/format for the property type. + "internal/damage": 2, + "internal/engine": 3, "internal/audit": 3, @@ -146,6 +158,14 @@ var sameLayerAllowed = map[string][]string{ "internal/format/wav": {"internal/format", "internal/format/imagelabel"}, "internal/preset": {"internal/recipe"}, + // A recipe checks the damages it names against the registry that holds + // them, the same way it checks a format against the format registry. It + // has to happen here rather than in the engine, because an invalid recipe + // writes no files at all and comes back with every problem it has - a name + // checked later would be a run that started before its recipe was known to + // be good. + "internal/recipe": {"internal/damage"}, + // The window is composed of parts and the parts know nothing about // windows. That direction is what lets a part be rendered on its own, // which is where the golden images sit - an image of a whole screen diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index c38a3e4..6de453d 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -65,6 +65,16 @@ var reachableFromTheWindow = []string{ // menu. "preset:size-boundaries.format", + // What to break about the files, drawn from the damage registry rather + // than listed in the window, with the parameters of whatever is chosen + // drawn by the same call that draws a format's settings. + // + // Pressed rather than looked at: TestARunFromTheWindowIsReallyDamaged sets + // the menu and a parameter, presses Generate and reads the BYTES back, so + // a window that drew the control and dropped the choice on the way to the + // engine would redden here rather than passing as a drawn box. + "recipe:targets.damage", + // Every format the registry holds, taken from the registry itself rather // than listed in the window. TestTheWindowOffersEveryFormatTheRegistryHas. "format:bmp", @@ -219,15 +229,21 @@ var reachableFromTheWindow = []string{ // parity, written down rather than estimated. // // Some entries are here for a second reason - the engine refuses them too, so -// neither surface has them. extends, with, policy, engine, targets.mutations, -// targets.fill, defaults.fill and output.split_threshold are all answered today -// with "not in this build yet". +// neither surface has them. extends, with, policy, engine, targets.fill, +// defaults.fill and output.split_threshold are all answered today with "not in +// this build yet". +// +// That is seven, and it was eight until 2026-09-09: targets.mutations was +// refused with a message pointing at a module that will never exist, and it is +// now targets.damage, which both surfaces reach. It left this list rather than +// moving down it, which is what this list is for - the distance to parity is +// only allowed to shrink. // -// That is eight, and this sentence said seven until 2026-08-18: it had left out -// output.split_threshold, which recipe.go has refused all along. Counted from -// the code that does the refusing rather than from this list, which is the only -// way it could have been found - a comment has no guard, and the number here -// looked as settled as the ones a test prints. +// The sentence said seven once before, until 2026-08-18, and was wrong: it had +// left out output.split_threshold, which recipe.go has refused all along. +// Counted from the code that does the refusing rather than from this list, +// which is the only way it could have been found - a comment has no guard, and +// the number here looked as settled as the ones a test prints. // // They stay on this list because a key nobody has built is still a // key the window cannot produce, and separating the two reasons would be a @@ -241,7 +257,6 @@ var notYetReachable = []string{ "recipe:output.split_threshold", "recipe:policy", "recipe:targets.fill", - "recipe:targets.mutations", "recipe:version", "recipe:with", } diff --git a/internal/guard/recipeshapes_test.go b/internal/guard/recipeshapes_test.go index b8871d8..d08911d 100644 --- a/internal/guard/recipeshapes_test.go +++ b/internal/guard/recipeshapes_test.go @@ -109,7 +109,11 @@ var shapeCases = map[string]string{ "expected": skipShapeCase, // properties is a mapping of names to values. "properties": skipShapeCase, - "mutations": skipShapeCase, + // damage takes a list, so a mapping really is a wrong shape for it - it + // gets a case rather than a skip. The entries inside it may be a word or a + // mapping, which is a different question and is asked by the guards in + // damagerefused_test.go. + "damage": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n damage: {a: b}\noutput:\n dir: ./o\n", "version": "version: {a: b}\ntargets:\n - id: a\n format: txt\n size: 1kb\noutput:\n dir: ./o\n", "seed": "version: 1\nseed: {a: b}\ntargets:\n - id: a\n format: txt\n size: 1kb\noutput:\n dir: ./o\n", diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go index 86f8c68..68967c1 100644 --- a/internal/guard/settingslot_test.go +++ b/internal/guard/settingslot_test.go @@ -185,8 +185,10 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T) "entries": "an", "bit_depth": "a", "sample_rate": "a", "channels": "a", "paragraphs": "a", "rows": "a", "columns": "a", "slides": "a", "depth": "a", "colours": "a", "records": "a", "lines": "a", + "damage": "a", "bytes": "a", // Labels, which is what a window shows. "Batch name": "a", "How many files": "a", "File names": "a", "Size": "a", + "Damage": "a", "Format": "a", "Seed": "a", "Output directory": "an", "Kind of case": "a", "Around a limit": "an", "Size range": "a", "Expected outcome": "an", "Limit to test": "a", "One size": "a", "A range": "a", diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 3fc18f9..2de30dd 100644 Binary files a/internal/guard/testdata/screens/generate-chosen-by-key.png and b/internal/guard/testdata/screens/generate-chosen-by-key.png differ diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.xml b/internal/guard/testdata/screens/generate-chosen-by-key.xml index f5d397b..585d4e7 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index 134d822..afc3048 100644 Binary files a/internal/guard/testdata/screens/generate-chosen.png and b/internal/guard/testdata/screens/generate-chosen.png differ diff --git a/internal/guard/testdata/screens/generate-chosen.xml b/internal/guard/testdata/screens/generate-chosen.xml index 1c13a8a..dd75651 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index e3ba87d..2a47fc5 100644 Binary files a/internal/guard/testdata/screens/generate-empty.png and b/internal/guard/testdata/screens/generate-empty.png differ diff --git a/internal/guard/testdata/screens/generate-empty.xml b/internal/guard/testdata/screens/generate-empty.xml index e5e6eff..e2f5dda 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -242,10 +242,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index 2cf1535..6a8801c 100644 Binary files a/internal/guard/testdata/screens/generate-focused.png and b/internal/guard/testdata/screens/generate-focused.png differ diff --git a/internal/guard/testdata/screens/generate-focused.xml b/internal/guard/testdata/screens/generate-focused.xml index d6d91bd..5952793 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -235,10 +235,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index 87def7f..0cec8e2 100644 Binary files a/internal/guard/testdata/screens/generate-hovered.png and b/internal/guard/testdata/screens/generate-hovered.png differ diff --git a/internal/guard/testdata/screens/generate-hovered.xml b/internal/guard/testdata/screens/generate-hovered.xml index 37db1fd..214fadf 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 9f15be1..472a394 100644 Binary files a/internal/guard/testdata/screens/generate-menu-hovered.png and b/internal/guard/testdata/screens/generate-menu-hovered.png differ diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml index 62f74cc..f53e725 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 2201cb3..9236c1d 100644 Binary files a/internal/guard/testdata/screens/generate-menu-keyed.png and b/internal/guard/testdata/screens/generate-menu-keyed.png differ diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml index bc55d39..eba6e8d 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 63b4541..f5303b3 100644 Binary files a/internal/guard/testdata/screens/generate-menu.png and b/internal/guard/testdata/screens/generate-menu.png differ diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml index 9c83d42..c98df7a 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index d7b7a7b..f30ff93 100644 Binary files a/internal/guard/testdata/screens/generate-refused-both.png and b/internal/guard/testdata/screens/generate-refused-both.png differ diff --git a/internal/guard/testdata/screens/generate-refused-both.xml b/internal/guard/testdata/screens/generate-refused-both.xml index 9e3e85a..ac83992 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -249,10 +249,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index 93627aa..a1bf699 100644 Binary files a/internal/guard/testdata/screens/generate-refused-setting.png and b/internal/guard/testdata/screens/generate-refused-setting.png differ diff --git a/internal/guard/testdata/screens/generate-refused-setting.xml b/internal/guard/testdata/screens/generate-refused-setting.xml index 0f1beb6..87e10d5 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -316,10 +316,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index 6f70fdf..58b8f02 100644 Binary files a/internal/guard/testdata/screens/generate-refused.png and b/internal/guard/testdata/screens/generate-refused.png differ diff --git a/internal/guard/testdata/screens/generate-refused.xml b/internal/guard/testdata/screens/generate-refused.xml index 5e9830f..1fb6536 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -244,10 +244,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index 1152d9f..d61f9dc 100644 Binary files a/internal/guard/testdata/screens/generate-switch-by-key.png and b/internal/guard/testdata/screens/generate-switch-by-key.png differ diff --git a/internal/guard/testdata/screens/generate-switch-by-key.xml b/internal/guard/testdata/screens/generate-switch-by-key.xml index 33119bf..fc02678 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index 3ea5863..1891819 100644 Binary files a/internal/guard/testdata/screens/generate-typed.png and b/internal/guard/testdata/screens/generate-typed.png differ diff --git a/internal/guard/testdata/screens/generate-typed.xml b/internal/guard/testdata/screens/generate-typed.xml index 59b4e68..f8027e0 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -241,10 +241,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index bb498b4..711f651 100644 Binary files a/internal/guard/testdata/screens/generate-unchecked.png and b/internal/guard/testdata/screens/generate-unchecked.png differ diff --git a/internal/guard/testdata/screens/generate-unchecked.xml b/internal/guard/testdata/screens/generate-unchecked.xml index 2e77aad..94d0e35 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index f5dea61..2775e20 100644 Binary files a/internal/guard/testdata/screens/generate.png and b/internal/guard/testdata/screens/generate.png differ diff --git a/internal/guard/testdata/screens/generate.xml b/internal/guard/testdata/screens/generate.xml index 385d183..7720c77 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -35,10 +35,10 @@ - - - - + + + + File configuration @@ -234,10 +234,43 @@ + + + + + + Damage + + + + + + + + + + + + + + + none + + + + + + + + + + + + - + diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index c154a75..b470af1 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -55,10 +55,22 @@ "description": "Shown where an archive says what it holds.", "other": "Files inside each archive" }, + "DamageNone": { + "description": "Shown in the window.", + "other": "none" + }, + "DamageSettingsFor": { + "description": "Shown in the window. Carries one value, {{.Damage}}, which has to stay spelled exactly that way.", + "other": "Settings for {{.Damage}}" + }, "DetailBoundary": { "description": "The longer explanation behind the button beside a field name.", "other": "Give the limit your system declares, as 10mb. Units count in 1024s, and the run prints the number it used." }, + "DetailDamage": { + "description": "The longer explanation behind the button beside a field name.", + "other": "The files come out the size you asked for and no reader will accept them, which is what a validator has to reject. The manifest records what was broken and says the file is expected to be rejected." + }, "DetailDonate": { "description": "The longer explanation behind the button beside a field name.", "other": "Opens the support page in your browser. The tool is free and stays free - this pays for the time that goes into it." @@ -119,6 +131,10 @@ "description": "The name above a box somebody fills in.", "other": "How many files" }, + "FieldDamage": { + "description": "The name above a box somebody fills in.", + "other": "Damage" + }, "FieldExpected": { "description": "The name above a box somebody fills in.", "other": "Expected outcome" @@ -192,6 +208,10 @@ "description": "The line under a field name, saying what the field does.", "other": "Three files: one byte under the limit, one on it, one over." }, + "HintDamage": { + "description": "The line under a field name, saying what the field does.", + "other": "Break the files on purpose." + }, "HintExpected": { "description": "The line under a field name, saying what the field does.", "other": "What the system under test should do with these files." diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 97e66e4..8f5078d 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -224,6 +224,31 @@ func PlaceholderLeftEmpty(declared string) string { // PlaceholderNotStated stays. It is a different thing: a menu with no declared // default, where nothing chosen really does mean nothing stated, and the // manifest really does record it as unspecified. +// The damage field. A file broken on purpose is the one thing this tool makes +// that is not well formed, so the words around it say what will happen rather +// than what it is called. +func FieldDamage() string { return say("FieldDamage", "Damage") } + +func HintDamage() string { + return say("HintDamage", "Break the files on purpose.") +} + +func DetailDamage() string { + return say("DetailDamage", "The files come out the size you asked for and no reader will accept them, which is what a validator has to reject. The manifest records what was broken and says the file is expected to be rejected.") +} + +// DamageNone is the entry that means no damage, and it is what the menu opens +// on. A menu cannot be empty, so "not damaged" has to be one of its values. +func DamageNone() string { return say("DamageNone", "none") } + +// DamageSettingsFor heads the block of fields a chosen damage declares. +// +// A function rather than a constant with the name glued on, for the reason +// SettingsFor gives below. +func DamageSettingsFor(damageID string) string { + return sayf("DamageSettingsFor", "Settings for {{.Damage}}", map[string]any{"Damage": damageID}) +} + // SettingsFor heads the block of fields a chosen format declares. // // A function rather than a constant with the id glued on: languages do not diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index b5708f7..312388d 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -7,8 +7,10 @@ import ( "fyne.io/fyne/v2/container" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" // The formats register themselves when this package is pulled in, and // without it the registry the menu is built from is empty. // @@ -119,6 +121,14 @@ type Generate struct { // the settings of a PNG are not the settings of a WAV. props []parts.PropertyField propBox *fyne.Container + + // What to break about the files, in one piece rather than five fields. + // + // Grouped because the type ceiling is a real limit and the answer to it is + // to move state out rather than raise the number - and because these five + // are one thing: a menu, the fields whatever it chose declares, the box + // they sit in and whether that box is folded away. + damage damagePanel // settings is the fold those fields are put in, and settingsFolded is // whether it is away. The flag lives on the screen rather than in the fold // because the fold is built again on every change of format - one that @@ -261,6 +271,16 @@ func (g *Generate) buildFields() { g.formatPick.SetSelected(ids[0]) } + // Every damage this build registered, asked of the registry rather than + // listed here - so a second one appears in this menu on the day it is + // registered. DamageNone leads, because a menu cannot be empty and "not + // damaged" is what this screen opens on: damage is the exception rather + // than the ordinary run. + g.damage.box = parts.FieldColumn() + g.damage.pick = parts.NewChooser(append([]string{text.DamageNone()}, damage.Names()...), + g.onDamageChosen) + g.damage.pick.SetSelected(text.DamageNone()) + g.size = entry("10mb", "") g.count = entry("1", "") g.id = entry("files", "") @@ -328,6 +348,12 @@ func (g *Generate) settingsSection() []fyne.CanvasObject { // The settings the chosen format declares land here, under the ones // every format has. g.propBox, + // Damage sits under them rather than beside the format, because it + // is a question about the finished file rather than about which + // file to make. + add(recipe.KeyDamage, text.FieldDamage(), text.HintDamage(), + g.tips.Say(text.DetailDamage()), g.damage.pick), + g.damage.box, ), parts.Section(text.SectionOutput(), add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()), @@ -358,6 +384,12 @@ func (g *Generate) onFormatChosen(id string) { // put, under widgets no longer on the screen. g.fields.KeepFirst(g.fixed) + // The damage parameters sit after the same mark, so they were just taken + // away too. Deferred rather than called at the end because this function + // has three ways out, and the one that returns early on an unknown format + // would otherwise leave the screen holding a menu with no fields under it. + defer g.rebuildDamageFields() + d, err := format.Get(id) if err != nil { // The registry filled this menu, so this cannot happen from a press. It @@ -387,6 +419,98 @@ func (g *Generate) onFormatChosen(id string) { g.propBox.Refresh() } +// onDamageChosen redraws the fields the chosen damage declares. +// +// A press only. The rebuild that follows a change of format goes through +// rebuildDamageFields directly, because the format did not change what was +// chosen here - it took the widgets away and they have to come back the same. + +// damagePanel is the damage half of the generate screen. +// +// The menu is built with the fields that are always here, because the chosen +// format must not take it away. Its PARAMETERS are rebuilt whenever the format +// is - they sit after the same mark, so a change of format takes them with it, +// and rebuilding both from one place keeps one rule instead of two. +type damagePanel struct { + pick *parts.Chooser + props []parts.PropertyField + box *fyne.Container + fold *parts.Folding + // folded is whether the parameters are away, kept here rather than in the + // fold because the fold is built again on every rebuild and one that + // remembered nothing would spring open unasked. + folded bool +} + +func (g *Generate) onDamageChosen(string) { + if !g.ready { + return + } + g.rebuildDamageFields() +} + +// rebuildDamageFields draws the parameters of whatever damage is chosen. +// +// Nothing when the choice is none, which is what this screen opens on. The +// fields come from parts.DeclaredFields, the same call the format settings and +// the preset parameters go through - so a damage that gains a parameter gains +// its field with no window code at all, which is what makes the registry +// answer D1 rather than this screen answering it. +func (g *Generate) rebuildDamageFields() { + g.damage.box.RemoveAll() + g.damage.props = nil + g.damage.fold = nil + + id := g.damage.pick.Selected + if id == "" || id == text.DamageNone() { + g.damage.box.Refresh() + return + } + + d, err := damage.Get(id) + if err != nil { + // The registry filled this menu, so a press cannot get here. A build + // where the two have come apart can, and saying so beats a screen with + // a damage chosen and no reason given. + g.refuse(err) + g.damage.box.Refresh() + return + } + + fields, objects := parts.DeclaredFields(d.Parameters, g.fields, g.tips) + g.damage.props = fields + if len(objects) == 0 { + g.damage.box.Refresh() + return + } + + g.damage.fold = parts.NewInnerFolding(text.DamageSettingsFor(d.ID), objects...) + g.damage.fold.OnChange = func(open bool) { g.damage.folded = !open } + g.damage.fold.Set(!g.damage.folded) + g.damage.box.Add(g.damage.fold.Object()) + g.damage.box.Refresh() +} + +// chosenDamage is what the screen asks the engine to break, or nothing. +// +// One entry rather than a list, and that is a limit of this screen rather than +// of the engine: the chain carries as many as a recipe names, and this menu +// offers one. The batch screen is where a list belongs, next to the other +// things a recipe says and a single target does not. +func (g *Generate) chosenDamage() damage.Chain { + id := g.damage.pick.Selected + if id == "" || id == text.DamageNone() { + return nil + } + values := damage.Values{} + for _, f := range g.damage.props { + if v := f.Value(); v != "" { + values[f.Name] = v + } + } + return damage.Chain{{ID: id, Values: values}} +} + // settingsSaid is what the folded settings section says about itself. // // Only what was stated, and it matters here more than anywhere: this section @@ -508,6 +632,7 @@ func (g *Generate) settle() ([]engine.Target, engine.Options, error) { NameTmpl: g.name.Text, Label: g.label.Checked, Properties: g.properties(), + Damage: g.chosenDamage(), }}, engine.Options{ OutDir: g.outDir.Text, Seed: seed, diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index 7ee7dc2..1fa05dc 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -324,9 +324,7 @@ func engineTarget(t recipe.Target) engine.Target { Sizes: t.Sizes, Contains: contentsOf(t), SizeFromContents: t.SizeFromContents, - SizeIsRange: t.SizeIsRange, - SizeMin: t.SizeMin, - SizeMax: t.SizeMax, + Range: engine.SizeRange(t.Range), BoundaryLimit: t.BoundaryLimit, NameTmpl: t.Name, Label: t.Label, diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 370ff70..4748d35 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -234,6 +234,23 @@ type File struct { LabelEmbedded bool `json:"label_embedded"` + // Damage is what was deliberately broken about this file, in the order it + // was applied. + // + // Left out entirely for a file nothing damaged, rather than written as an + // empty list, so a consumer can tell "not damaged" from "damaged with + // nothing" - and so every manifest written before this existed stays byte + // for byte what it was. + // + // The order is part of it. Two damages applied the other way round are + // different bytes, and D11 promises those bytes do not move, so a record + // that lost the order would describe a file that cannot be rebuilt from it. + // + // Adding the field does not move manifest_version, the same way the + // toolchain field did not: a reader of schema 1.0 that does not know this + // key ignores it, and no key it does know has changed meaning. + Damage []Damage `json:"damage,omitempty"` + // Notes are the things that must not be swallowed - a label that did not // fit, a fidelity level lowered on the fly, a file that failed. Notes []Note `json:"notes,omitempty"` @@ -244,6 +261,17 @@ type File struct { Error string `json:"error,omitempty"` } +// Damage is one thing broken about a file, with the settings it was given. +// +// The settings are recorded as they were resolved rather than as they were +// written, defaults included, because a consumer asking "what was done to this +// file" should not have to know what the default was in the build that wrote +// it. +type Damage struct { + Type string `json:"type"` + Settings map[string]string `json:"settings,omitempty"` +} + // Hashes identify the bytes. type Hashes struct { SHA256 string `json:"sha256"` diff --git a/internal/recipe/compose.go b/internal/recipe/compose.go index 71f8de2..4f2aa2e 100644 --- a/internal/recipe/compose.go +++ b/internal/recipe/compose.go @@ -340,7 +340,7 @@ const ( KeyGroup = "group" KeyLabel = "label" KeyFill = "fill" - KeyMutations = "mutations" + KeyDamage = "damage" KeyProperties = core.KeyProperties KeyExpected = "expected" KeyExpectedReason = "expected.reason" diff --git a/internal/recipe/damage.go b/internal/recipe/damage.go new file mode 100644 index 0000000..0d20cf6 --- /dev/null +++ b/internal/recipe/damage.go @@ -0,0 +1,188 @@ +package recipe + +import ( + "errors" + "fmt" + "sort" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// KeyDamageType is the key inside a damage entry that names which damage it is. +const KeyDamageType = "type" + +// damages reads what a target said it wanted broken. +// +// A list rather than one value, from the first day. Composition is a +// requirement rather than an extension, and the shape has to carry it before +// anything is written against the contract - a single value promoted to a list +// later would be a change to a public name under untouchable rule 10. +// +// An entry is a word or a mapping, which is how expected already reads. The +// word is the common case and stays short, and the mapping is there the moment +// a damage takes settings. +func damages(p *problems, where spot, raw []any) damage.Chain { + if raw == nil { + return nil + } + if len(raw) == 0 { + // Not silently nothing. Somebody wrote the key, so they are expecting + // broken files, and a run that quietly produced whole ones would be + // the silence untouchable rule 6 forbids. + p.add(where.of(KeyDamage), fmt.Sprintf("%s says damage and names none", where), + "a target that damages nothing produces the same files as one that does not mention it", + "name a damage, or remove the line") + return nil + } + + chain := make(damage.Chain, 0, len(raw)) + for i, entry := range raw { + if spec, ok := oneDamage(p, where.entry(KeyDamage, i), entry); ok { + chain = append(chain, spec) + } + } + return chain +} + +// oneDamage reads a single entry of the list. +func oneDamage(p *problems, at spot, entry any) (damage.Spec, bool) { + switch x := entry.(type) { + case string: + return checkedDamage(p, at, x, damage.Values{}) + case map[string]any: + return mappedDamage(p, at, x) + default: + p.add(at.of(KeyDamageType), fmt.Sprintf("%s is neither a name nor a set of settings", at), + "a damage is written as its name, or as a mapping with type and the settings it takes", + "write the name on its own, or a mapping starting with type") + return damage.Spec{}, false + } +} + +// mappedDamage reads the long form: a type and the settings for it. +func mappedDamage(p *problems, at spot, x map[string]any) (damage.Spec, bool) { + rawType, stated := x[KeyDamageType] + if !stated { + p.add(at.of(KeyDamageType), fmt.Sprintf("%s does not say which damage it is", at), + "a damage written as a mapping names itself with type", + "add type, with the name of the damage") + return damage.Spec{}, false + } + id, isScalar := scalarText(rawType) + if !isScalar { + p.add(at.of(KeyDamageType), fmt.Sprintf("%s names a damage that is not a word", at), + "the type of a damage is the name this build knows it by", + "write the name as a word") + return damage.Spec{}, false + } + + values := damage.Values{} + for _, key := range sortedKeysOf(x) { + if key == KeyDamageType { + continue + } + text, single := scalarText(x[key]) + if !single { + p.add(at.of(key), fmt.Sprintf("%s: %s is a list or a block", at, key), + "a damage setting takes one value, the way a format property does", + "give it one value, for example bytes: 16") + continue + } + values[key] = text + } + return checkedDamage(p, at, id, values) +} + +// checkedDamage refuses a damage this build does not know, and a setting its +// declaration does not allow. +// +// The wording comes from the declaration rather than from here, which is the +// whole reason a damage parameter is a format.Property: a mistyped setting on +// a damage and a mistyped setting on a format are refused in one voice, and +// neither copies the other's sentences. Same shape as askTheFormat, one axis +// over. +func checkedDamage(p *problems, at spot, id string, values damage.Values) (damage.Spec, bool) { + d, err := damage.Get(id) + if err != nil { + var unknown *damage.UnknownError + if errors.As(err, &unknown) { + p.add(at.of(KeyDamageType), fmt.Sprintf("%s: %s", at, unknown.What()), + unknown.Why(), unknown.Instead()) + } else { + p.add(at.of(KeyDamageType), fmt.Sprintf("%s: %s", at, err.Error()), "", "") + } + return damage.Spec{}, false + } + + ok := true + for _, bad := range d.CheckEach(values) { + reportDamageSetting(p, at, bad) + ok = false + } + return damage.Spec{ID: id, Values: values}, ok +} + +// reportDamageSetting puts one refusal on the box it belongs to. +// +// The same three branches askTheFormat keeps, and for the reason written there: +// every problem the registry returns names its key today, and a fourth kind +// added without one has to arrive unaddressed rather than vanish. +func reportDamageSetting(p *problems, at spot, bad error) { + var about interface{ AboutSetting() string } + if !errors.As(bad, &about) { + p.add(at.of(KeyDamage), bad.Error(), "", "") + return + } + where := at.of(about.AboutSetting()) + + var value *format.PropertyValueError + if errors.As(bad, &value) { + p.add(where, fmt.Sprintf("%s: %s cannot be %q", at, value.Key, value.Value), + core.InTheWordsOf(value.Reason, value.Key), value.Remedy) + return + } + var unknown *format.UnknownPropertyError + if errors.As(bad, &unknown) { + p.add(where, fmt.Sprintf("%s: %s", at, unknown.What()), unknown.Why(), unknown.Instead()) + return + } + p.add(where, fmt.Sprintf("%s: %s", at, bad.Error()), "", "") +} + +// refuseImpossibleExpectation stops a target that breaks a file and expects it +// to be accepted. +// +// Only accept, and the narrowness is the decision rather than caution. reject +// is what damage implies and is what a target gets when it says nothing. +// sanitize and unspecified are both sensible questions about a broken file - a +// system under test may be expected to repair it, or that may be exactly what +// the test is asking - so neither is touched. +// +// The alternative was letting one side win quietly, and both ways of doing +// that are worse. The recipe winning puts accept in the manifest about a file +// a judge was measured to refuse, which is the tool lying in the one place its +// value lives. Damage winning overwrites what somebody wrote, and the +// regression surface says an expectation stated in a recipe reaches the +// manifest unchanged. Owner's call on 2026-09-09. +func refuseImpossibleExpectation(p *problems, where spot, t Target) { + if len(t.Damage) == 0 || t.Expected != outcomeAccept { + return + } + refusal := &damage.ExpectationConflictError{Outcome: t.Expected} + p.add(where.of(refusal.AboutSetting()), + fmt.Sprintf("%s: %s", where, refusal.What()), + refusal.Why(), refusal.Instead()) +} + +// sortedKeysOf puts the settings of one entry in a stable order, so the same +// recipe always reports the same one first. +func sortedKeysOf(x map[string]any) []string { + out := make([]string, 0, len(x)) + for key := range x { + out = append(out, key) + } + sort.Strings(out) + return out +} diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go index cff7179..45c6954 100644 --- a/internal/recipe/recipe.go +++ b/internal/recipe/recipe.go @@ -9,6 +9,8 @@ import ( "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" ) // SchemaVersion is the recipe schema this build understands. It is versioned @@ -77,22 +79,40 @@ type Target struct { // the preset it came from. Group string Properties map[string]string + // Damage is what to break about these files, in the order to break it. + // + // Empty for every target that does not ask, which is every target written + // before this existed - and that is what keeps D11 whole: a run with no + // damage goes through the same writer it always did. + Damage damage.Chain // Contains is what a container holds, one entry per group. Contains []Content // SizeFromContents is set when contains was given without a size, so the // container works the size out from what it holds. SizeFromContents bool - // SizeMin and SizeMax hold the range when the target asked for one, and - // SizeIsRange says it did. The sizes themselves are not settled here. + // Range holds what a target asked for when it asked for one. The sizes + // themselves are not settled here. + // + // One field rather than three, for the reason engine.Target gives: the + // type stood at the crowding band, and these three were one statement all + // along - a minimum without the flag beside it says nothing. // // They cannot be. A range is drawn from the seed, and the --seed flag // overrides the recipe after this package has finished reading it, so a // size drawn at validation time would belong to a different run than the // one the manifest describes. The engine draws them, which is still before // anything reaches the disk, so AR10 holds and --dry-run stays exact. - SizeIsRange bool - SizeMin int64 - SizeMax int64 + Range SizeRange +} + +// SizeRange is a size drawn per file rather than stated. +// +// Its own type here as well as in the engine rather than one shared between +// them, because this package stays a description of a recipe and the engine +// stays what runs one - the same reason Content exists twice. +type SizeRange struct { + Used bool + Min, Max int64 } // Content is one group of files inside a container. diff --git a/internal/recipe/target.go b/internal/recipe/target.go index d26a1e3..21ac53e 100644 --- a/internal/recipe/target.go +++ b/internal/recipe/target.go @@ -39,8 +39,23 @@ type rawTarget struct { Boundary *scalar `yaml:"boundary"` SizeRange *scalar `yaml:"size-range"` Contains []map[string]scalar `yaml:"contains"` - Mutations []map[string]any `yaml:"mutations"` - Fill *scalar `yaml:"fill"` + + // Damage is what to break about the files, in the order to break it. + // + // []any rather than a list of mappings, because an entry is a word or a + // mapping - the shape expected already has, for the same reason: the + // common case is one name and it should stay one word. + // + // It replaced a reserved key called mutations, which this build refused + // with a message pointing at a module that will never exist. The rename + // was free only because the key was always refused and the manifest field + // beside it was always empty, so nobody was standing on either. In this + // project a mutation is a change to the CODE that proves a guard works, + // with its own runner and its own chapter, and one word for two things is + // what GLOSSARY.md exists to prevent. + Damage []any `yaml:"damage"` + + Fill *scalar `yaml:"fill"` } // DefaultCount is how many files a target produces when it does not say. @@ -109,7 +124,9 @@ func (rt rawTarget) validate(p *problems, index int, def Defaults) Target { t.Label = *rt.Label } + t.Damage = damages(p, where, rt.Damage) t.Expected, t.ExpectedReason = expectation(p, where, rt.Expected) + refuseImpossibleExpectation(p, where, t) if group, ok := oneValue(p, where.of("group"), where.String()+" {setting}", "group: invoices", rt.Group); ok { t.Group = group } @@ -119,10 +136,6 @@ func (rt rawTarget) validate(p *problems, index int, def Defaults) Target { // refuseSections names the parts of a target this build cannot honour. func (rt rawTarget) refuseSections(p *problems, where spot) { - if rt.Mutations != nil { - p.notYetIn(where, "mutations", "damaged files arrive with the Chaos Lab", - "remove the section") - } if rt.Fill != nil { p.notYetIn(where, "fill", "the fill mode is not settable yet", "remove the line - content is generated from the seed") @@ -209,7 +222,7 @@ func (rt rawTarget) resolveSize(p *problems, where spot, count int, t *Target) { if !ok { break } - t.SizeIsRange, t.SizeMin, t.SizeMax = true, low, high + t.Range.Used, t.Range.Min, t.Range.Max = true, low, high // The values stay zero here and the engine replaces them. What this // list carries at this point is the number of files, exactly as it // does for contains. @@ -309,7 +322,15 @@ func KnownReason(r string) bool { return reasons[r] } // command line twice, and the window was about to be the fifth copy. That is // exactly the argument written above Reasons - two copies of a closed list is // how the surfaces drift, which is D1 one level down. -var outcomes = []string{"accept", "reject", "sanitize", "unspecified"} +var outcomes = []string{outcomeAccept, "reject", "sanitize", "unspecified"} + +// outcomeAccept is the one outcome a damaged file cannot have. +// +// Named rather than typed twice, because the refusal in damage.go is about this +// exact word. Below the list rather than above it: the paragraph over the list +// documents the LIST, and a declaration slipped in between would quietly take +// that paragraph for itself and leave the list with none. +const outcomeAccept = "accept" // Outcomes is the closed list, for the surfaces that have to offer it. //